49 Commits

Author SHA1 Message Date
88a9a7709f feat: Added interactive prompting between the LLM and the user in Sisyphus using the built-in Bash utils scripts
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-02-16 13:57:04 -07:00
github-actions[bot]
e8d92d1b01 chore: bump Cargo.toml to 0.2.0
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-02-14 01:41:41 +00:00
github-actions[bot]
ddbfd03e75 bump: version 0.1.3 → 0.2.0 [skip ci] 2026-02-14 01:41:29 +00:00
d1c7f09015 feat: Simplified sisyphus prompt to improve functionality
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-02-13 18:36:10 -07:00
d2f8f995f0 feat: Supported the injection of RAG sources into the prompt, not just via the .sources rag command in the REPL so models can directly reference the documents that supported their responses
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-02-13 17:45:56 -07:00
5ef9a397ca docs: updated the tools documentation to mention the new fs_read, fs_grep, and fs_glob tools
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-02-13 16:53:00 -07:00
325ab1f45e docs: updated the default configuration example to have the new fs_read, fs_glob, fs_grep global functions
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-02-13 16:23:49 -07:00
4cfaa2dc77 docs: Updated the docs to mention the new agents
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-02-13 15:42:28 -07:00
6abe2c5536 feat: Created the Sisyphus agent to make Loki function like Claude Code, Gemini, Codex, etc. 2026-02-13 15:42:10 -07:00
03cfd59962 feat: Created the Oracle agent to handle high-level architectural decisions and design questions about a given codebase 2026-02-13 15:41:44 -07:00
4d7d5e5e53 feat: Updated the coder agent to be much more task-focused and to be delegated to by Sisyphus 2026-02-13 15:41:11 -07:00
3779b940ae feat: Created the explore agent for exploring codebases to help answer questions 2026-02-13 15:40:46 -07:00
d2e541c5c0 docs: Updated todo-system docs 2026-02-13 15:13:37 -07:00
621c90427c feat: Use the official atlassian MCP server for the jira-helper agent 2026-02-13 14:56:42 -07:00
486001ee85 feat: Created fs_glob to enable more targeted file exploration utilities 2026-02-13 13:31:50 -07:00
c7a2ec084f feat: Created a new tool 'fs_grep' to search a given file's contents for relevant lines to reduce token usage for smaller models 2026-02-13 13:31:20 -07:00
d4e0d48198 feat: Created the new fs_read tool to enable controlled reading of a file 2026-02-13 13:30:53 -07:00
07f23bab5e feat: Let agent level variables be defined to bypass guard protections for tool invocations 2026-02-09 16:45:11 -07:00
b11797ea1c fix: Improved continuation prompt to not make broad todo-items 2026-02-09 15:36:57 -07:00
70c2d411ae fix: Allow auto-continuation to work in agents after a session is compressed and if there's still unfinish items in the to-do list 2026-02-09 15:21:39 -07:00
f82c9aff40 fix: fs_ls and fs_cat outputs should always redirect to "$LLM_OUTPUT" including on errors. 2026-02-09 14:56:55 -07:00
a935add2a7 feat: Implemented a built-in task management system to help smaller LLMs complete larger multistep tasks and minimize context drift 2026-02-09 12:49:06 -07:00
8a37a88ffd feat: Improved tool and MCP invocation error handling by returning stderr to the model when it is available 2026-02-04 12:00:21 -07:00
8f66cac680 feat: Added variable interpolation for conversation starters in agents
CI / All (ubuntu-latest) (push) Failing after 5m27s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-02-04 10:51:59 -07:00
0a40ddd2e4 build: Upgraded to the most recent version of gman to fix vault vulnerabilities
CI / All (ubuntu-latest) (push) Failing after 5m27s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-02-03 09:24:53 -07:00
d5e0728532 feat: Implemented retry logic for failed tool invocations so the LLM can learn from the result and try again; Also implemented chain loop detection to prevent loops
CI / All (ubuntu-latest) (push) Failing after 5m27s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-02-01 17:06:16 -07:00
25c0885dcc fix: Claude tool calls work incorrectly when tool doesn't require any arguments or flags; would provide an empty JSON object or error on no args 2026-02-01 17:05:36 -07:00
f56ed7d005 feat: Added gemini-3-pro to the supported vertexai models 2026-01-30 19:03:41 -07:00
d79e4b9dff Fixed some typos in tool call error messages 2026-01-30 12:25:57 -07:00
cdd829199f build: Created justfile to make life easier
CI / All (ubuntu-latest) (push) Failing after 5m26s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-01-27 13:49:36 -07:00
e3c644b8ca docs: Created a CREDITS file to document the history and origins of Loki from the original AIChat project
CI / All (ubuntu-latest) (push) Failing after 5m28s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-01-27 13:15:20 -07:00
5cb8070da1 build: Support Claude Opus 4.5
CI / All (ubuntu-latest) (push) Failing after 5m26s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-01-26 12:40:06 -07:00
66801b5d07 feat: Added an environment variable that lets users bypass guard operations in bash scripts. This is useful for agent routing
CI / All (ubuntu-latest) (push) Failing after 5m29s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-01-23 14:18:52 -07:00
f2de196e22 fix: Fixed a bug where --agent-variable values were not being passed to the agents 2026-01-23 14:15:59 -07:00
2eba530895 feat: Added support for thought-signatures for Gemini 3+ models
CI / All (ubuntu-latest) (push) Failing after 5m25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-01-21 15:11:55 -07:00
3baa3102a3 style: Cleaned up an anyhow error
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2025-12-16 14:51:35 -07:00
github-actions[bot]
2d4fad596c bump: version 0.1.2 → 0.1.3 [skip ci] 2025-12-13 20:57:37 +00:00
7259e59d2a ci: Prep for 0.1.3 release 2025-12-13 13:38:09 -07:00
cec04c4597 style: Improved error message for un-fully configured MCP configuration 2025-12-13 13:37:01 -07:00
github-actions[bot]
a7f5677195 chore: bump Cargo.toml to 0.1.3 2025-12-13 20:28:10 +00:00
github-actions[bot]
6075f0a190 bump: version 0.1.2 → 0.1.3 [skip ci] 2025-12-13 20:27:58 +00:00
15310a9e2c chore: Updated the models 2025-12-11 09:05:41 -07:00
f7df54f2f7 docs: Removed the warning about MCP token usage since that has been fixed 2025-12-05 12:38:15 -07:00
212d4bace4 docs: Fixed an unclosed backtick typo in the Environment Variables docs 2025-12-05 12:37:59 -07:00
f4b3267c89 docs: Fixed typo in vault readme 2025-12-05 11:05:14 -07:00
9eeeb11871 style: Applied formatting 2025-12-03 15:06:50 -07:00
b8db3f689d Merge branch 'main' of github.com:Dark-Alex-17/loki 2025-12-03 14:57:03 -07:00
3b21ce2aa5 feat: Improved MCP implementation to minimize the tokens needed to utilize it so it doesn't quickly overwhelm the token space for a given model 2025-12-03 12:12:51 -07:00
Alex Clarke
9bf4fcd943 ci: Updated the README to be a bit more clear in some sections 2025-11-26 15:53:54 -07:00
59 changed files with 5081 additions and 1394 deletions
+37
View File
@@ -1,3 +1,40 @@
## v0.2.0 (2026-02-14)
### Feat
- Simplified sisyphus prompt to improve functionality
- Supported the injection of RAG sources into the prompt, not just via the `.sources rag` command in the REPL so models can directly reference the documents that supported their responses
- Created the Sisyphus agent to make Loki function like Claude Code, Gemini, Codex, etc.
- Created the Oracle agent to handle high-level architectural decisions and design questions about a given codebase
- Updated the coder agent to be much more task-focused and to be delegated to by Sisyphus
- Created the explore agent for exploring codebases to help answer questions
- Use the official atlassian MCP server for the jira-helper agent
- Created fs_glob to enable more targeted file exploration utilities
- Created a new tool 'fs_grep' to search a given file's contents for relevant lines to reduce token usage for smaller models
- Created the new fs_read tool to enable controlled reading of a file
- Let agent level variables be defined to bypass guard protections for tool invocations
- Implemented a built-in task management system to help smaller LLMs complete larger multistep tasks and minimize context drift
- Improved tool and MCP invocation error handling by returning stderr to the model when it is available
- Added variable interpolation for conversation starters in agents
- Implemented retry logic for failed tool invocations so the LLM can learn from the result and try again; Also implemented chain loop detection to prevent loops
- Added gemini-3-pro to the supported vertexai models
- Added an environment variable that lets users bypass guard operations in bash scripts. This is useful for agent routing
- Added support for thought-signatures for Gemini 3+ models
### Fix
- Improved continuation prompt to not make broad todo-items
- Allow auto-continuation to work in agents after a session is compressed and if there's still unfinish items in the to-do list
- fs_ls and fs_cat outputs should always redirect to "$LLM_OUTPUT" including on errors.
- Claude tool calls work incorrectly when tool doesn't require any arguments or flags; would provide an empty JSON object or error on no args
- Fixed a bug where --agent-variable values were not being passed to the agents
## v0.1.3 (2025-12-13)
### Feat
- Improved MCP implementation to minimize the tokens needed to utilize it so it doesn't quickly overwhelm the token space for a given model
## v0.1.2 (2025-11-08)
### Refactor
+2 -1
View File
@@ -48,7 +48,8 @@ cz commit
1. Clone this repo
2. Run `cargo test` to set up hooks
3. Make changes
4. Run the application using `make run` or `cargo run`
4. Run the application using `just run` or `just run`
- Install `just` (`cargo install just`) if you haven't already to use the [justfile](./justfile) in this project.
5. Commit changes. This will trigger pre-commit hooks that will run format, test and lint. If there are errors or
warnings from Clippy, please fix them.
6. Push your code to a new branch named after the feature/bug/etc. you're adding. This will trigger pre-push hooks that
+31
View File
@@ -0,0 +1,31 @@
# Credits
## AIChat
Loki originally started as a fork of the fantastic
[AIChat CLI](https://github.com/sigoden/aichat). The initial goal was simply
to fix a bug in how MCP servers worked with AIChat, allowing different MCP
servers to be specified per agent. Since then, Loki has evolved far beyond
its original scope and grown into a passion project with a life of its own.
Today, Loki includes first-class MCP server support (for both local and remote
servers), a built-in vault for interpolating secrets in configuration files,
built-in agents and macros, dynamic tab completions, integrated custom
functions (no external `argc` dependency), improved documentation, and much
more with many more ideas planned for the future.
Loki is now developed and maintained as an independent project. Full credit
for the original foundation goes to the developers of the wonderful
AIChat project.
This project is not affiliated with or endorsed by the AIChat maintainers.
## AIChat
Loki originally began as a fork of [AIChat CLI](https://github.com/sigoden/aichat),
created and maintained by the AIChat contributors.
While Loki has since diverged significantly and is now developed as an
independent project, its early foundation and inspiration came from the
AIChat project.
AIChat is licensed under the MIT License.
Generated
+907 -642
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "loki-ai"
version = "0.1.2"
version = "0.2.0"
edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "An all-in-one, batteries included LLM CLI Tool"
@@ -94,7 +94,7 @@ rustpython-parser = "0.4.0"
rustpython-ast = "0.4.0"
colored = "3.0.0"
clap_complete = { version = "4.5.58", features = ["unstable-dynamic"] }
gman = "0.2.3"
gman = "0.3.0"
clap_complete_nushell = "4.5.9"
[dependencies.reqwest]
+13 -17
View File
@@ -19,7 +19,6 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
## Quick Links
* [AIChat Migration Guide](./docs/AICHAT-MIGRATION.md): Coming from AIChat? Follow the migration guide to get started.
* [History](#history): A history of how Loki came to be.
* [Installation](#install): Install Loki
* [Getting Started](#getting-started): Get started with Loki by doing first-run setup steps.
* [REPL](./docs/REPL.md): Interactive Read-Eval-Print Loop for conversational interactions with LLMs and Loki.
@@ -37,31 +36,18 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
* [Sessions](/docs/SESSIONS.md): Manage and persist conversational contexts and settings across multiple interactions.
* [Roles](./docs/ROLES.md): Customize model behavior for specific tasks or domains.
* [Agents](/docs/AGENTS.md): Leverage AI agents to perform complex tasks and workflows.
* [Todo System](./docs/TODO-SYSTEM.md): Built-in task tracking for improved agent reliability with smaller models.
* [Environment Variables](./docs/ENVIRONMENT-VARIABLES.md): Override and customize your Loki configuration at runtime with environment variables.
* [Client Configurations](./docs/clients/CLIENTS.md): Configuration instructions for various LLM providers.
* [Patching API Requests](./docs/clients/PATCHES.md): Learn how to patch API requests for advanced customization.
* [Custom Themes](./docs/THEMES.md): Change the look and feel of Loki to your preferences with custom themes.
---
## History
Loki originally started as a fork of the fantastic [AIChat CLI](https://github.com/sigoden/aichat). The purpose was to
simply fix a bug in how MCP servers worked with AIChat so that I could specify different ones for agents. However, it
has since evolved far beyond that and become a passion project with a life of its own!
Loki now has first class MCP server support (with support for local and remote servers alike), a built-in vault for
interpolating secrets in configuration files, built-in agents, built-in macros, dynamic tab completions, integrated
custom functions (no `argc` dependency), improved documentation, and much more with many more plans for the future!
The original kudos goes out to all the developers of the wonderful AIChat project!
---
* [History](#history): A history of how Loki came to be.
## Prerequisites
Loki requires the following tools to be installed on your system:
* [jq](https://github.com/jqlang/jq)
* `brew install jq`
* [jira (optional)](https://github.com/ankitpokhrel/jira-cli/wiki/Installation) (For the `jira-helper` agent)
* [jira (optional)](https://github.com/ankitpokhrel/jira-cli/wiki/Installation) (For the `query_jira_issues` tool)
* `brew tap ankitpokhrel/jira-cli && brew install jira-cli`
* You'll need to [create a JIRA API token](https://id.atlassian.com/manage-profile/security/api-tokens) for authentication
* Then, save it as an environment variable to your shell profile:
@@ -257,5 +243,15 @@ The appearance of Loki can be modified using the following settings:
| `user_agent` | `null` | The name of the `User-Agent` that should be passed in the `User-Agent` header on all requests to model providers |
| `save_shell_history` | `true` | Enables or disables REPL command history |
---
## History
Loki began as a fork of [AIChat CLI](https://github.com/sigoden/aichat) and has since evolved into an independent project.
See [CREDITS.md](./CREDITS.md) for full attribution and background.
---
## Creator
* [Alex Clarke](https://github.com/Dark-Alex-17)
+447
View File
@@ -0,0 +1,447 @@
#!/usr/bin/env bash
# Shared Agent Utilities - Minimal, focused helper functions
set -euo pipefail
#############################
## CONTEXT FILE MANAGEMENT ##
#############################
get_context_file() {
local project_dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
echo "${project_dir}/.loki-context"
}
# Initialize context file for a new task
# Usage: init_context "Task description"
init_context() {
local task="$1"
local project_dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
local context_file
context_file=$(get_context_file)
cat > "${context_file}" <<EOF
## Project: ${project_dir}
## Task: ${task}
## Started: $(date -Iseconds)
### Prior Findings
EOF
}
# Append findings to the context file
# Usage: append_context "agent_name" "finding summary
append_context() {
local agent="$1"
local finding="$2"
local context_file
context_file=$(get_context_file)
if [[ -f "${context_file}" ]]; then
{
echo ""
echo "[${agent}]:"
echo "${finding}"
} >> "${context_file}"
fi
}
# Read the current context (returns empty string if no context)
# Usage: context=$(read_context)
read_context() {
local context_file
context_file=$(get_context_file)
if [[ -f "${context_file}" ]]; then
cat "${context_file}"
fi
}
# Clear the context file
clear_context() {
local context_file
context_file=$(get_context_file)
rm -f "${context_file}"
}
#######################
## PROJECT DETECTION ##
#######################
# Cache file name for detected project info
_LOKI_PROJECT_CACHE=".loki-project.json"
# Read cached project detection if valid
# Usage: _read_project_cache "/path/to/project"
# Returns: cached JSON on stdout (exit 0) or nothing (exit 1)
_read_project_cache() {
local dir="$1"
local cache_file="${dir}/${_LOKI_PROJECT_CACHE}"
if [[ -f "${cache_file}" ]]; then
local cached
cached=$(cat "${cache_file}" 2>/dev/null) || return 1
if echo "${cached}" | jq -e '.type and .build != null and .test != null and .check != null' &>/dev/null; then
echo "${cached}"
return 0
fi
fi
return 1
}
# Write project detection result to cache
# Usage: _write_project_cache "/path/to/project" '{"type":"rust",...}'
_write_project_cache() {
local dir="$1"
local json="$2"
local cache_file="${dir}/${_LOKI_PROJECT_CACHE}"
echo "${json}" > "${cache_file}" 2>/dev/null || true
}
_detect_heuristic() {
local dir="$1"
# Rust
if [[ -f "${dir}/Cargo.toml" ]]; then
echo '{"type":"rust","build":"cargo build","test":"cargo test","check":"cargo check"}'
return 0
fi
# Go
if [[ -f "${dir}/go.mod" ]]; then
echo '{"type":"go","build":"go build ./...","test":"go test ./...","check":"go vet ./..."}'
return 0
fi
# Node.JS/Deno/Bun
if [[ -f "${dir}/deno.json" ]] || [[ -f "${dir}/deno.jsonc" ]]; then
echo '{"type":"deno","build":"deno task build","test":"deno test","check":"deno lint"}'
return 0
fi
if [[ -f "${dir}/package.json" ]]; then
local pm="npm"
[[ -f "${dir}/bun.lockb" ]] || [[ -f "${dir}/bun.lock" ]] && pm="bun"
[[ -f "${dir}/pnpm-lock.yaml" ]] && pm="pnpm"
[[ -f "${dir}/yarn.lock" ]] && pm="yarn"
echo "{\"type\":\"nodejs\",\"build\":\"${pm} run build\",\"test\":\"${pm} test\",\"check\":\"${pm} run lint\"}"
return 0
fi
# Python
if [[ -f "${dir}/pyproject.toml" ]] || [[ -f "${dir}/setup.py" ]] || [[ -f "${dir}/setup.cfg" ]]; then
local test_cmd="pytest"
local check_cmd="ruff check ."
if [[ -f "${dir}/poetry.lock" ]]; then
test_cmd="poetry run pytest"
check_cmd="poetry run ruff check ."
elif [[ -f "${dir}/uv.lock" ]]; then
test_cmd="uv run pytest"
check_cmd="uv run ruff check ."
fi
echo "{\"type\":\"python\",\"build\":\"\",\"test\":\"${test_cmd}\",\"check\":\"${check_cmd}\"}"
return 0
fi
# JVM (Maven)
if [[ -f "${dir}/pom.xml" ]]; then
echo '{"type":"java","build":"mvn compile","test":"mvn test","check":"mvn verify"}'
return 0
fi
# JVM (Gradle)
if [[ -f "${dir}/build.gradle" ]] || [[ -f "${dir}/build.gradle.kts" ]]; then
local gw="gradle"
[[ -f "${dir}/gradlew" ]] && gw="./gradlew"
echo "{\"type\":\"java\",\"build\":\"${gw} build\",\"test\":\"${gw} test\",\"check\":\"${gw} check\"}"
return 0
fi
# .NET / C#
if compgen -G "${dir}/*.sln" &>/dev/null || compgen -G "${dir}/*.csproj" &>/dev/null; then
echo '{"type":"dotnet","build":"dotnet build","test":"dotnet test","check":"dotnet build --warnaserrors"}'
return 0
fi
# C/C++ (CMake)
if [[ -f "${dir}/CMakeLists.txt" ]]; then
echo '{"type":"cmake","build":"cmake --build build","test":"ctest --test-dir build","check":"cmake --build build"}'
return 0
fi
# Ruby
if [[ -f "${dir}/Gemfile" ]]; then
local test_cmd="bundle exec rake test"
[[ -f "${dir}/Rakefile" ]] && grep -q "rspec" "${dir}/Gemfile" 2>/dev/null && test_cmd="bundle exec rspec"
echo "{\"type\":\"ruby\",\"build\":\"\",\"test\":\"${test_cmd}\",\"check\":\"bundle exec rubocop\"}"
return 0
fi
# Elixir
if [[ -f "${dir}/mix.exs" ]]; then
echo '{"type":"elixir","build":"mix compile","test":"mix test","check":"mix credo"}'
return 0
fi
# PHP
if [[ -f "${dir}/composer.json" ]]; then
echo '{"type":"php","build":"","test":"./vendor/bin/phpunit","check":"./vendor/bin/phpstan analyse"}'
return 0
fi
# Swift
if [[ -f "${dir}/Package.swift" ]]; then
echo '{"type":"swift","build":"swift build","test":"swift test","check":"swift build"}'
return 0
fi
# Zig
if [[ -f "${dir}/build.zig" ]]; then
echo '{"type":"zig","build":"zig build","test":"zig build test","check":"zig build"}'
return 0
fi
# Generic build systems (last resort before LLM)
if [[ -f "${dir}/justfile" ]] || [[ -f "${dir}/Justfile" ]]; then
echo '{"type":"just","build":"just build","test":"just test","check":"just lint"}'
return 0
fi
if [[ -f "${dir}/Makefile" ]] || [[ -f "${dir}/makefile" ]] || [[ -f "${dir}/GNUmakefile" ]]; then
echo '{"type":"make","build":"make build","test":"make test","check":"make lint"}'
return 0
fi
return 1
}
# Gather lightweight evidence about a project for LLM analysis
# Usage: _gather_project_evidence "/path/to/project"
# Returns: evidence string on stdout
_gather_project_evidence() {
local dir="$1"
local evidence=""
evidence+="Root files and directories:"$'\n'
evidence+=$(ls -1 "${dir}" 2>/dev/null | head -50)
evidence+=$'\n\n'
evidence+="File extension counts:"$'\n'
evidence+=$(find "${dir}" -type f \
-not -path '*/.git/*' \
-not -path '*/node_modules/*' \
-not -path '*/target/*' \
-not -path '*/dist/*' \
-not -path '*/__pycache__/*' \
-not -path '*/vendor/*' \
-not -path '*/.build/*' \
2>/dev/null \
| sed 's/.*\.//' | sort | uniq -c | sort -rn | head -10)
evidence+=$'\n\n'
local config_patterns=("*.toml" "*.yaml" "*.yml" "*.json" "*.xml" "*.gradle" "*.gradle.kts" "*.cabal" "*.pro" "Makefile" "justfile" "Justfile" "Dockerfile" "Taskfile*" "BUILD" "WORKSPACE" "flake.nix" "shell.nix" "default.nix")
local found_configs=0
for pattern in "${config_patterns[@]}"; do
if [[ ${found_configs} -ge 5 ]]; then
break
fi
local files
files=$(find "${dir}" -maxdepth 1 -name "${pattern}" -type f 2>/dev/null)
while IFS= read -r f; do
if [[ -n "${f}" && ${found_configs} -lt 5 ]]; then
local basename
basename=$(basename "${f}")
evidence+="--- ${basename} (first 30 lines) ---"$'\n'
evidence+=$(head -30 "${f}" 2>/dev/null)
evidence+=$'\n\n'
found_configs=$((found_configs + 1))
fi
done <<< "${files}"
done
echo "${evidence}"
}
# LLM-based project detection fallback
# Usage: _detect_with_llm "/path/to/project"
# Returns: JSON on stdout or empty (exit 1)
_detect_with_llm() {
local dir="$1"
local evidence
evidence=$(_gather_project_evidence "${dir}")
local prompt
prompt=$(cat <<-EOF
Analyze this project directory and determine the project type, primary language, and the correct shell commands to build, test, and check (lint/typecheck) it.
EOF
)
prompt+=$'\n'"${evidence}"$'\n'
prompt+=$(cat <<-EOF
Respond with ONLY a valid JSON object. No markdown fences, no explanation, no extra text.
The JSON must have exactly these 4 keys:
{"type":"<language>","build":"<build command>","test":"<test command>","check":"<lint or typecheck command>"}
Rules:
- "type" must be a single lowercase word (e.g. rust, go, python, nodejs, java, ruby, elixir, cpp, c, zig, haskell, scala, kotlin, dart, swift, php, dotnet, etc.)
- If a command doesn't apply to this project, use an empty string, ""
- Use the most standard/common commands for the detected ecosystem
- If you detect a package manager lockfile, use that package manager (e.g. pnpm over npm)
EOF
)
local llm_response
llm_response=$(loki --no-stream "${prompt}" 2>/dev/null) || return 1
llm_response=$(echo "${llm_response}" | sed 's/^```json//;s/^```//;s/```$//' | tr -d '\n' | sed 's/^[[:space:]]*//')
llm_response=$(echo "${llm_response}" | grep -o '{[^}]*}' | head -1)
if echo "${llm_response}" | jq -e '.type and .build != null and .test != null and .check != null' &>/dev/null; then
echo "${llm_response}" | jq -c '{type: (.type // "unknown"), build: (.build // ""), test: (.test // ""), check: (.check // "")}'
return 0
fi
return 1
}
# Detect project type and return build/test commands
# Uses: cached result -> fast heuristics -> LLM fallback
detect_project() {
local dir="${1:-.}"
local cached
if cached=$(_read_project_cache "${dir}"); then
echo "${cached}" | jq -c '{type, build, test, check}'
return 0
fi
local result
if result=$(_detect_heuristic "${dir}"); then
local enriched
enriched=$(echo "${result}" | jq -c '. + {"_detected_by":"heuristic","_cached_at":"'"$(date -Iseconds)"'"}')
_write_project_cache "${dir}" "${enriched}"
echo "${result}"
return 0
fi
if result=$(_detect_with_llm "${dir}"); then
local enriched
enriched=$(echo "${result}" | jq -c '. + {"_detected_by":"llm","_cached_at":"'"$(date -Iseconds)"'"}')
_write_project_cache "${dir}" "${enriched}"
echo "${result}"
return 0
fi
echo '{"type":"unknown","build":"","test":"","check":""}'
}
######################
## AGENT INVOCATION ##
######################
# Invoke a subagent with optional context injection
# Usage: invoke_agent <agent_name> <prompt> [extra_args...]
invoke_agent() {
local agent="$1"
local prompt="$2"
shift 2
local context
context=$(read_context)
local full_prompt
if [[ -n "${context}" ]]; then
full_prompt="## Orchestrator Context
The orchestrator (sisyphus) has gathered this context from prior work:
<context>
${context}
</context>
## Your Task
${prompt}"
else
full_prompt="${prompt}"
fi
env AUTO_CONFIRM=true loki --agent "${agent}" "$@" "${full_prompt}" 2>&1
}
# Invoke a subagent and capture a summary of its findings
# Usage: result=$(invoke_agent_with_summary "explore" "find auth patterns")
invoke_agent_with_summary() {
local agent="$1"
local prompt="$2"
shift 2
local output
output=$(invoke_agent "${agent}" "${prompt}" "$@")
local summary=""
if echo "${output}" | grep -q "FINDINGS:"; then
summary=$(echo "${output}" | sed -n '/FINDINGS:/,/^[A-Z_]*COMPLETE/p' | grep "^- " | sed 's/^- / - /')
elif echo "${output}" | grep -q "CODER_COMPLETE:"; then
summary=$(echo "${output}" | grep "CODER_COMPLETE:" | sed 's/CODER_COMPLETE: *//')
elif echo "${output}" | grep -q "ORACLE_COMPLETE"; then
summary=$(echo "${output}" | sed -n '/^## Recommendation/,/^## /{/^## Recommendation/d;/^## /d;p}' | sed '/^$/d' | head -10)
fi
# Failsafe: extract up to 5 meaningful lines if no markers found
if [[ -z "${summary}" ]]; then
summary=$(echo "${output}" | grep -v "^$" | grep -v "^#" | grep -v "^\-\-\-" | tail -10 | head -5)
fi
if [[ -n "${summary}" ]]; then
append_context "${agent}" "${summary}"
fi
echo "${output}"
}
###########################
## FILE SEARCH UTILITIES ##
###########################
search_files() {
local pattern="$1"
local dir="${2:-.}"
find "${dir}" -type f -name "${pattern}" \
-not -path '*/target/*' \
-not -path '*/node_modules/*' \
-not -path '*/.git/*' \
-not -path '*/dist/*' \
-not -path '*/__pycache__/*' \
2>/dev/null | head -25
}
get_tree() {
local dir="${1:-.}"
local depth="${2:-3}"
if command -v tree &>/dev/null; then
tree -L "${depth}" --noreport -I 'node_modules|target|dist|.git|__pycache__|*.pyc' "${dir}" 2>/dev/null || find "${dir}" -maxdepth "${depth}" -type f | head -50
else
find "${dir}" -maxdepth "${depth}" -type f \
-not -path '*/target/*' \
-not -path '*/node_modules/*' \
-not -path '*/.git/*' \
2>/dev/null | head -50
fi
}
+4 -4
View File
@@ -2,6 +2,9 @@
An AI agent that assists you with your coding tasks.
This agent is designed to be delegated to by the **[Sisyphus](../sisyphus/README.md)** agent to implement code specifications. Sisyphus
acts as the coordinator/architect, while Coder handles the implementation details.
## Features
- 🏗️ Intelligent project structure creation and management
@@ -10,7 +13,4 @@ An AI agent that assists you with your coding tasks.
- 🧐 Advanced code analysis and improvement suggestions
- 📊 Precise diff-based file editing for controlled code modifications
## Similar Projects
- https://github.com/Doriandarko/claude-engineer
- https://github.com/paul-gauthier/aider
It can also be used as a standalone tool for direct coding assistance.
+92 -40
View File
@@ -1,53 +1,105 @@
name: Coder
description: An AI agent that assists you with your coding tasks
version: 0.1.0
name: coder
description: Implementation agent - writes code, follows patterns, verifies with builds
version: 1.0.0
temperature: 0.1
top_p: 0.95
auto_continue: true
max_auto_continues: 15
inject_todo_instructions: true
variables:
- name: project_dir
description: Project directory to work in
default: '.'
global_tools:
- fs_mkdir.sh
- fs_ls.sh
- fs_read.sh
- fs_grep.sh
- fs_glob.sh
- fs_write.sh
- fs_patch.sh
- fs_cat.sh
- execute_command.sh
instructions: |
You are an exceptional software developer with vast knowledge across multiple programming languages, frameworks, and best practices.
Your capabilities include:
You are a senior engineer. You write code that works on the first try.
1. Creating and managing project structures
2. Writing, debugging, and improving code across multiple languages
3. Providing architectural insights and applying design patterns
4. Staying current with the latest technologies and best practices
5. Analyzing and manipulating files within the project directory
## Your Mission
Available tools and their optimal use cases:
Given an implementation task:
1. Understand what to build (from context provided)
2. Study existing patterns (read 1-2 similar files)
3. Write the code (using tools, NOT chat output)
4. Verify it compiles/builds
5. Signal completion
1. fs_mkdir: Create new directories in the project structure.
2. fs_create: Generate new files with specified contents.
3. fs_patch: Examine and modify existing files.
4. fs_cat: View the contents of existing files without making changes.
5. fs_ls: Understand the current project structure or locate specific files.
## Todo System
Tool Usage Guidelines:
- Always use the most appropriate tool for the task at hand.
- For file modifications, use fs_patch. Read the file first, then apply changes if needed.
- After making changes, always review the diff output to ensure accuracy.
For multi-file changes:
1. `todo__init` with the implementation goal
2. `todo__add` for each file to create/modify
3. Implement each, calling `todo__done` immediately after
Project Creation and Management:
1. Start by creating a root folder for new projects.
2. Create necessary subdirectories and files within the root folder.
3. Organize the project structure logically, following best practices for the specific project type.
## Writing Code
Code Editing Best Practices:
1. Always read the file content before making changes.
2. Analyze the code and determine necessary modifications.
3. Pay close attention to existing code structure to avoid unintended alterations.
4. Review changes thoroughly after each modification.
**CRITICAL**: Write code using `write_file` tool, NEVER paste code in chat.
Always strive for accuracy, clarity, and efficiency in your responses and actions.
Correct:
```
write_file --path "src/user.rs" --content "pub struct User { ... }"
```
Answer the user's request using relevant tools (if they are available). Before calling a tool, do some analysis within <thinking></thinking> tags. First, think about which of the provided tools is the relevant tool to answer the user's request. Second, go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, close the thinking tag and proceed with the tool call. BUT, if one of the values for a required parameter is missing, DO NOT invoke the function (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters. DO NOT ask for more information on optional parameters if it is not provided.
Wrong:
```
Here's the implementation:
\`\`\`rust
pub struct User { ... }
\`\`\`
```
Do not reflect on the quality of the returned search results in your response.
## File Reading Strategy (IMPORTANT - minimize token usage)
1. **Use grep to find relevant code** - `fs_grep --pattern "fn handle_request" --include "*.rs"` finds where things are
2. **Read only what you need** - `fs_read --path "src/main.rs" --offset 50 --limit 30` reads lines 50-79
3. **Never cat entire large files** - If 500+ lines, read the relevant section after grepping for it
4. **Use glob to find files** - `fs_glob --pattern "*.rs" --path src/` discovers files by name
## Pattern Matching
Before writing ANY file:
1. Find a similar existing file (use `fs_grep` to locate, then `fs_read` to examine)
2. Match its style: imports, naming, structure
3. Follow the same patterns exactly
## Verification
After writing files:
1. Run `verify_build` to check compilation
2. If it fails, fix the error (minimal change)
3. Don't move on until build passes
## Completion Signal
End with:
```
CODER_COMPLETE: [summary of what was implemented]
```
Or if failed:
```
CODER_FAILED: [what went wrong]
```
## Rules
1. **Write code via tools** - Never output code to chat
2. **Follow patterns** - Read existing files first
3. **Verify builds** - Don't finish without checking
4. **Minimal fixes** - If build fails, fix precisely
5. **No refactoring** - Only implement what's asked
## Context
- Project: {{project_dir}}
- CWD: {{__cwd__}}
- Shell: {{__shell__}}
conversation_starters:
- 'Create a new Python project structure for a web application'
- 'Explain the code in file.py and suggest improvements'
- 'Search for the latest best practices in React development'
- 'Help me debug this error: [paste your error message]'
+190 -12
View File
@@ -1,18 +1,196 @@
#!/usr/bin/env bash
set -e
# @env LLM_OUTPUT=/dev/stdout The output path
set -eo pipefail
# shellcheck disable=SC1090
source "$LLM_PROMPT_UTILS_FILE"
source "$LLM_ROOT_DIR/agents/.shared/utils.sh"
# @cmd Create a new file at the specified path with the given contents.
# @option --path! The path where the file should be created
# @option --contents! The contents of the file
# shellcheck disable=SC2154
fs_create() {
guard_path "$argc_path" "Create '$argc_path'?"
mkdir -p "$(dirname "$argc_path")"
printf "%s" "$argc_contents" > "$argc_path"
echo "File created: $argc_path" >> "$LLM_OUTPUT"
# @env LLM_OUTPUT=/dev/stdout
# @env LLM_AGENT_VAR_PROJECT_DIR=.
# @describe Coder agent tools for implementing code changes
_project_dir() {
local dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
}
# @cmd Read a file's contents before modifying
# @option --path! Path to the file (relative to project root)
read_file() {
# shellcheck disable=SC2154
local file_path="${argc_path}"
local project_dir
project_dir=$(_project_dir)
local full_path="${project_dir}/${file_path}"
if [[ ! -f "${full_path}" ]]; then
warn "File not found: ${file_path}" >> "$LLM_OUTPUT"
return 0
fi
{
info "Reading: ${file_path}"
echo ""
cat "${full_path}"
} >> "$LLM_OUTPUT"
}
# @cmd Write complete file contents
# @option --path! Path for the file (relative to project root)
# @option --content! Complete file contents to write
write_file() {
local file_path="${argc_path}"
# shellcheck disable=SC2154
local content="${argc_content}"
local project_dir
project_dir=$(_project_dir)
local full_path="${project_dir}/${file_path}"
mkdir -p "$(dirname "${full_path}")"
echo "${content}" > "${full_path}"
green "Wrote: ${file_path}" >> "$LLM_OUTPUT"
}
# @cmd Find files similar to a given path (for pattern matching)
# @option --path! Path to find similar files for
find_similar_files() {
local file_path="${argc_path}"
local project_dir
project_dir=$(_project_dir)
local ext="${file_path##*.}"
local dir
dir=$(dirname "${file_path}")
info "Similar files to: ${file_path}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local results
results=$(find "${project_dir}/${dir}" -maxdepth 1 -type f -name "*.${ext}" \
! -name "$(basename "${file_path}")" \
! -name "*test*" \
! -name "*spec*" \
2>/dev/null | head -3)
if [[ -z "${results}" ]]; then
results=$(find "${project_dir}/src" -type f -name "*.${ext}" \
! -name "*test*" \
! -name "*spec*" \
-not -path '*/target/*' \
2>/dev/null | head -3)
fi
if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT"
else
warn "No similar files found" >> "$LLM_OUTPUT"
fi
}
# @cmd Verify the project builds successfully
verify_build() {
local project_dir
project_dir=$(_project_dir)
local project_info
project_info=$(detect_project "${project_dir}")
local build_cmd
build_cmd=$(echo "${project_info}" | jq -r '.check // .build')
if [[ -z "${build_cmd}" ]] || [[ "${build_cmd}" == "null" ]]; then
warn "No build command detected" >> "$LLM_OUTPUT"
return 0
fi
info "Running: ${build_cmd}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local output exit_code=0
output=$(cd "${project_dir}" && eval "${build_cmd}" 2>&1) || exit_code=$?
echo "${output}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
if [[ ${exit_code} -eq 0 ]]; then
green "BUILD SUCCESS" >> "$LLM_OUTPUT"
return 0
else
error "BUILD FAILED (exit code: ${exit_code})" >> "$LLM_OUTPUT"
return 1
fi
}
# @cmd Run project tests
run_tests() {
local project_dir
project_dir=$(_project_dir)
local project_info
project_info=$(detect_project "${project_dir}")
local test_cmd
test_cmd=$(echo "${project_info}" | jq -r '.test')
if [[ -z "${test_cmd}" ]] || [[ "${test_cmd}" == "null" ]]; then
warn "No test command detected" >> "$LLM_OUTPUT"
return 0
fi
info "Running: ${test_cmd}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local output exit_code=0
output=$(cd "${project_dir}" && eval "${test_cmd}" 2>&1) || exit_code=$?
echo "${output}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
if [[ ${exit_code} -eq 0 ]]; then
green "TESTS PASSED" >> "$LLM_OUTPUT"
return 0
else
error "TESTS FAILED (exit code: ${exit_code})" >> "$LLM_OUTPUT"
return 1
fi
}
# @cmd Get project structure for context
get_project_structure() {
local project_dir
project_dir=$(_project_dir)
local project_info
project_info=$(detect_project "${project_dir}")
{
info "Project: $(echo "${project_info}" | jq -r '.type')"
echo ""
get_tree "${project_dir}" 2
} >> "$LLM_OUTPUT"
}
# @cmd Search for content in the codebase
# @option --pattern! Pattern to search for
search_code() {
# shellcheck disable=SC2154
local pattern="${argc_pattern}"
local project_dir
project_dir=$(_project_dir)
info "Searching: ${pattern}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local results
results=$(grep -rn "${pattern}" "${project_dir}" 2>/dev/null | \
grep -v '/target/' | \
grep -v '/node_modules/' | \
grep -v '/.git/' | \
head -20) || true
if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT"
else
warn "No matches" >> "$LLM_OUTPUT"
fi
}
+15
View File
@@ -0,0 +1,15 @@
# Explore
An AI agent specialized in exploring codebases, finding patterns, and understanding project structures.
This agent is designed to be delegated to by the **[Sisyphus](../sisyphus/README.md)** agent to gather information and context. Sisyphus
acts as the coordinator/architect, while Explore handles the research and discovery phase.
It can also be used as a standalone tool for understanding codebases and finding specific information.
## Features
- 🔍 Deep codebase exploration and pattern matching
- 📂 File system navigation and content analysis
- 🧠 Context gathering for complex tasks
- 🛡️ Read-only operations for safe investigation
+74
View File
@@ -0,0 +1,74 @@
name: explore
description: Fast codebase exploration agent - finds patterns, structures, and relevant files
version: 1.0.0
temperature: 0.1
top_p: 0.95
variables:
- name: project_dir
description: Project directory to explore
default: '.'
global_tools:
- fs_read.sh
- fs_grep.sh
- fs_glob.sh
- fs_ls.sh
instructions: |
You are a codebase explorer. Your job: Search, find, report. Nothing else.
## Your Mission
Given a search task, you:
1. Search for relevant files and patterns
2. Read key files to understand structure
3. Report findings concisely
4. Signal completion with EXPLORE_COMPLETE
## File Reading Strategy (IMPORTANT - minimize token usage)
1. **Find first, read second** - Never read a file without knowing why
2. **Use grep to locate** - `fs_grep --pattern "struct User" --include "*.rs"` finds exactly where things are
3. **Use glob to discover** - `fs_glob --pattern "*.rs" --path src/` finds files by name
4. **Read targeted sections** - `fs_read --path "src/main.rs" --offset 50 --limit 30` reads only lines 50-79
5. **Never read entire large files** - If a file is 500+ lines, read the relevant section only
## Available Actions
- `fs_grep --pattern "struct User" --include "*.rs"` - Find content across files
- `fs_glob --pattern "*.rs" --path src/` - Find files by name pattern
- `fs_read --path "src/main.rs"` - Read a file (with line numbers)
- `fs_read --path "src/main.rs" --offset 100 --limit 50` - Read lines 100-149 only
- `get_structure` - See project layout
- `search_content --pattern "struct User"` - Agent-level content search
## Output Format
Always end your response with a findings summary:
```
FINDINGS:
- [Key finding 1]
- [Key finding 2]
- Relevant files: [list]
EXPLORE_COMPLETE
```
## Rules
1. **Be fast** - Don't read every file, read representative ones
2. **Be focused** - Answer the specific question asked
3. **Be concise** - Report findings, not your process
4. **Never modify files** - You are read-only
5. **Limit reads** - Max 5 file reads per exploration
## Context
- Project: {{project_dir}}
- CWD: {{__cwd__}}
conversation_starters:
- 'Find how authentication is implemented'
- 'What patterns are used for API endpoints'
- 'Show me the project structure'
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
set -eo pipefail
# shellcheck disable=SC1090
source "$LLM_PROMPT_UTILS_FILE"
source "$LLM_ROOT_DIR/agents/.shared/utils.sh"
# @env LLM_OUTPUT=/dev/stdout
# @env LLM_AGENT_VAR_PROJECT_DIR=.
# @describe Explore agent tools for codebase search and analysis
_project_dir() {
local dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
}
# @cmd Get project structure and layout
get_structure() {
local project_dir
project_dir=$(_project_dir)
info "Project structure:" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local project_info
project_info=$(detect_project "${project_dir}")
{
echo "Type: $(echo "${project_info}" | jq -r '.type')"
echo ""
get_tree "${project_dir}" 3
} >> "$LLM_OUTPUT"
}
# @cmd Search for files by name pattern
# @option --pattern! File name pattern (e.g., "*.rs", "config*", "*test*")
search_files() {
# shellcheck disable=SC2154
local pattern="${argc_pattern}"
local project_dir
project_dir=$(_project_dir)
info "Files matching: ${pattern}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local results
results=$(search_files "${pattern}" "${project_dir}")
if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT"
else
warn "No files found" >> "$LLM_OUTPUT"
fi
}
# @cmd Search for content in files
# @option --pattern! Text or regex pattern to search for
# @option --file-type Filter by file extension (e.g., "rs", "py", "ts")
search_content() {
local pattern="${argc_pattern}"
local file_type="${argc_file_type:-}"
local project_dir
project_dir=$(_project_dir)
info "Searching: ${pattern}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local include_arg=""
if [[ -n "${file_type}" ]]; then
include_arg="--include=*.${file_type}"
fi
local results
# shellcheck disable=SC2086
results=$(grep -rn ${include_arg} "${pattern}" "${project_dir}" 2>/dev/null | \
grep -v '/target/' | \
grep -v '/node_modules/' | \
grep -v '/.git/' | \
grep -v '/dist/' | \
head -30) || true
if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT"
else
warn "No matches found" >> "$LLM_OUTPUT"
fi
}
# @cmd Read a file's contents
# @option --path! Path to the file (relative to project root)
# @option --lines Maximum lines to read (default: 200)
read_file() {
# shellcheck disable=SC2154
local file_path="${argc_path}"
local max_lines="${argc_lines:-200}"
local project_dir
project_dir=$(_project_dir)
local full_path="${project_dir}/${file_path}"
if [[ ! -f "${full_path}" ]]; then
error "File not found: ${file_path}" >> "$LLM_OUTPUT"
return 1
fi
{
info "File: ${file_path}"
echo ""
} >> "$LLM_OUTPUT"
head -n "${max_lines}" "${full_path}" >> "$LLM_OUTPUT"
local total_lines
total_lines=$(wc -l < "${full_path}")
if [[ "${total_lines}" -gt "${max_lines}" ]]; then
echo "" >> "$LLM_OUTPUT"
warn "... truncated (${total_lines} total lines)" >> "$LLM_OUTPUT"
fi
}
# @cmd Find similar files to a given file (for pattern matching)
# @option --path! Path to the reference file
find_similar() {
local file_path="${argc_path}"
local project_dir
project_dir=$(_project_dir)
local ext="${file_path##*.}"
local dir
dir=$(dirname "${file_path}")
info "Files similar to: ${file_path}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local results
results=$(find "${project_dir}/${dir}" -maxdepth 1 -type f -name "*.${ext}" \
! -name "$(basename "${file_path}")" \
! -name "*test*" \
! -name "*spec*" \
2>/dev/null | head -5)
if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT"
else
results=$(find "${project_dir}" -type f -name "*.${ext}" \
! -name "$(basename "${file_path}")" \
! -name "*test*" \
-not -path '*/target/*' \
2>/dev/null | head -5)
if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT"
else
warn "No similar files found" >> "$LLM_OUTPUT"
fi
fi
}
+7 -25
View File
@@ -2,31 +2,13 @@
## Overview
The Jira AI Agent is designed to assist with managing tasks within Jira projects, providing capabilities such as creating, searching, updating, assigning, linking, and commenting on issues. Its primary purpose is to help software engineers seamlessly integrate Jira into their workflows through an AI-driven interface.
The Jira AI Agent is designed to assist with managing tasks within Jira projects, providing capabilities such as
creating, searching, updating, assigning, linking, and commenting on issues. Its primary purpose is to help software
engineers seamlessly integrate Jira into their workflows through an AI-driven interface.
## Configuration
This agent uses the official [Atlassian MCP Server](https://github.com/atlassian/atlassian-mcp-server). To use it,
ensure you have Node.js v18+ installed to run the local MCP proxy (`mcp-remote`).
### Variables
This agent accepts the following variables:
- **config**: Specifies the configuration file for the Jira CLI. This configuration should be located at `~/.config/.jira/<config_name>.yml`. Example: `work`.
- **project**: The Jira project key where operations are executed. Example: `PAN`.
### Customization
#### For a User's Specific Jira Instance
1. **Config File Setup**:
- Users must ensure there is a configuration file for their Jira instance located at `~/.config/.jira/`. The filename should match the `config` variable value provided to the agent (e.g., for `config` set to `work`, ensure a `work.yml` exists).
2. **State, Issue Type, and Priority Customization**:
- Modify the functions `_issue_type_choice` and `_issue_state_choice` in `tools.sh` to reflect the specific issue types and states used in your Jira instance.
- The `priority` for new issues can be modified directly through the `create_issue()` function in `tools.sh` with options set to the values available in your Jira instance (e.g., Medium, Highest, etc.).
## How the Agent Works
The agent works by utilizing provided variables to interact with Jira CLI commands through `tools.sh`. The `config` variable links directly to a `.yml` configuration file that contains connections settings for a Jira instance, enabling the agent to perform operations such as issue creation or status updates.
- **Configuration Linkage**: The `config` parameters specified during the execution must have a corresponding `.yml` configuration file at `~/.config/.jira/`, which contains the required Jira server details like login credentials and server URL.
- **Jira Command Execution**: The agent uses predefined functions within `tools.sh` to execute Jira operations. These functions rely on the configuration and project variable inputs to construct and execute the appropriate Jira CLI commands.
The server uses OAuth 2.0 so it will automatically open your browser for you to sign in to your account. No manual
configuration is necessary!
+22 -10
View File
@@ -2,22 +2,34 @@ name: Jira Agent
description: An AI agent that can assist with Jira tasks such as creating issues, searching for issues, and updating issues.
version: 0.1.0
agent_session: temp
mcp_servers:
- atlassian
instructions: |
You are a AI agent designed to assist with managing Jira tasks and helping software engineers
utilize and integrate Jira into their workflows. You can create, search, update, assign, link, and comment on issues in Jira.
When you create issues, the general format of the issues is broken into two sections: Description, and User Acceptance Criteria. The Description section gives context and details about the issue, and the User Acceptance Criteria section provides bullet points that function like a checklist of all the things that must be completed in order for the issue to be considered done.
You are a AI agent designed to assist with managing Jira tasks and helping software engineers utilize and integrate
Jira into their workflows. You can create, search, update, assign, link, and comment on issues in Jira.
Create issues under the {{project}} Jira project.
## Create Issue (MANDATORY when creating a issue)
When a user prompts you to create a Jira issue:
1. Prompt the user for what Jira project they want the ticket created in
2. If the ticket type requires a parent issue:
a. Query Jira for potentially relevant parents
b. Prompt user for which parent to use, displaying the suggested list of parent issues
3. Create the issue with the following format:
```markdown
**Description:**
This section gives context and details about the issue.
**User Acceptance Criteria:**
# This section provides bullet points that function like a checklist of all the things that must be completed in
# order for the issue to be considered done.
* Example criteria one
* Example criteria two
```
4. Ask the user if the issue should be assigned to them
a. If yes, then assign the user to the newly created issue
Available tools:
{{__tools__}}
variables:
- name: config
description: The configuration to use for the Jira CLI; e.g. work
- name: project
description: The Jira project to operate on; e.g. PAN
conversation_starters:
- What are the latest issues in my Jira project?
- Can you create a new Jira issue for me?
-259
View File
@@ -1,259 +0,0 @@
#!/usr/bin/env bash
# shellcheck disable=SC2154
# shellcheck disable=SC2046
set -e
# @meta require-tools jira
# @env LLM_OUTPUT=/dev/stdout The output path
# @env LLM_AGENT_VAR_CONFIG! The configuration to use for the Jira CLI; e.g. work
# @env LLM_AGENT_VAR_PROJECT! The Jira project to operate on; e.g. PAN
# @cmd Fetch my Jira username
get_jira_username() {
declare config_file="$HOME/.config/.jira/${LLM_AGENT_VAR_CONFIG}.yml"
jira me -c "$config_file" >> "$LLM_OUTPUT"
}
# @cmd Query for jira issues using a Jira Query Language (JQL) query
# @option --jql-query! The Jira Query Language query to execute
# @option --project! $LLM_AGENT_VAR_PROJECT <PROJECT> Jira project to operate on; e.g. PAN
query_jira_issues() {
declare config_file="$HOME"/.config/.jira/"${LLM_AGENT_VAR_CONFIG}".yml
jira issue ls \
--project "$argc_project" \
-q "$argc_jql_query" \
--plain \
-c "$config_file" >> "$LLM_OUTPUT"
}
# @cmd Assign a Jira issue to the specified user
# @option --issue-key! The Jira issue key, e.g. ISSUE-1
# @option --assignee! The email or display name of the user to assign the issue to
# @option --project! $LLM_AGENT_VAR_PROJECT <PROJECT> Jira project to operate on; e.g. PAN
assign_jira_issue() {
declare config_file="$HOME"/.config/.jira/"${LLM_AGENT_VAR_CONFIG}".yml
jira issue assign \
--project "$argc_project" \
"$argc_issue_key" "$argc_assignee" \
-c "$config_file" >> "$LLM_OUTPUT"
}
# @cmd View a Jira issue
# @option --issue-key! The Jira issue key, e.g. ISSUE-1
# @option --project! $LLM_AGENT_VAR_PROJECT <PROJECT> Jira project to operate on; e.g. PAN
view_issue() {
declare config_file="$HOME"/.config/.jira/"${LLM_AGENT_VAR_CONFIG}".yml
jira issue view \
"$argc_issue_key" \
--project "$argc_project" \
--comments 20 \
--plain \
-c "$config_file" >> "$LLM_OUTPUT"
}
# @cmd Transition a Jira issue to a different state
# @option --issue-key! The Jira issue key, e.g. ISSUE-1
# @option --state![`_issue_state_choice`] The Jira state of the issue
# @option --comment Add a comment to the issue
# @option --resolution Set resolution
# @option --project! $LLM_AGENT_VAR_PROJECT <PROJECT> Jira project to operate on; e.g. PAN
transition_issue() {
declare config_file="$HOME"/.config/.jira/"${LLM_AGENT_VAR_CONFIG}".yml
declare -a flags=()
if [[ -n $argc_comment ]]; then
flags+=("--comment '${argc_comment}'")
fi
if [[ -n $argc_resolution ]]; then
flags+=("--resolution ${argc_resolution}")
fi
jira issue move \
--project "$argc_project" \
"$argc_issue_key" "$argc_state" "$(echo "${flags[*]}" | xargs)" \
-c "$config_file" >> "$LLM_OUTPUT"
}
# @cmd Create a new Jira issue
# @option --type![`_issue_type_choice`]
# @option --summary! Issue summary or title
# @option --description! Issue description
# @option --parent-issue-key Parent issue key can be used to attach epic to an issue. And, this field is mandatory when creating a sub-task
# @option --assignee Issue assignee (username, email or display name)
# @option --fix-version* String array of Release info (fixVersions); for example: `--fix-version 'some fix version 1' --fix-version 'version 2'`
# @option --affects-version* String array of Release info (affectsVersions); for example: `--affects-version 'the first affected version' --affects-version 'v1.2.3'`
# @option --label* String array of issue labels; for example: `--label backend --label custom`
# @option --component* String array of issue components; for example: `--component backend --component core`
# @option --original-estimate The original estimate of the issue
# @option --priority[=Medium|Highest|High|Low|Lowest] The priority of the issue
# @option --project! $LLM_AGENT_VAR_PROJECT <PROJECT> Jira project to operate on; e.g. PAN
create_issue() {
declare config_file="$HOME"/.config/.jira/"${LLM_AGENT_VAR_CONFIG}".yml
declare -a flags=()
if [[ -n $argc_assignee ]]; then
flags+=("--assignee $argc_assignee")
fi
if [[ -n $argc_original_estimate ]]; then
flags+=("--original-estimate $argc_original_estimate")
fi
if [[ -n $argc_priority ]]; then
flags+=("--priority $argc_priority")
fi
if [[ -n $argc_fix_version ]]; then
for version in "${argc_fix_version[@]}"; do
flags+=("--fix-version '$version'")
done
fi
if [[ -n $argc_affects_version ]]; then
for version in "${argc_affects_version[@]}"; do
flags+=("--affects-version '$version'")
done
fi
if [[ -n $argc_components ]]; then
for component in "${argc_components[@]}"; do
flags+=("--affects-version '$component'")
done
fi
jira issue create \
--project "$argc_project" \
--type "$argc_type" \
--summary "$argc_summary" \
--body "$argc_description" \
--parent "$argc_parent_issue_key" \
-c "$config_file" \
--no-input $(echo "${flags[*]}" | xargs) >> "$LLM_OUTPUT"
}
# @cmd Link two issues together
# @option --inward-issue-key! Issue key of the source issue, eg: ISSUE-1
# @option --outward-issue-key! Issue key of the target issue, eg: ISSUE-2
# @option --issue-link-type! Relationship between two issues, eg: Duplicates, Blocks etc.
# @option --project! $LLM_AGENT_VAR_PROJECT <PROJECT> Jira project to operate on; e.g. PAN
link_issues() {
declare config_file="$HOME"/.config/.jira/"${LLM_AGENT_VAR_CONFIG}".yml
jira issue link \
--project "$argc_project" \
"${argc_inward_issue_key}" "${argc_outward_issue_key}" "${argc_issue_link_type}" \
-c "$config_file" >> "$LLM_OUTPUT"
}
# @cmd Unlink or disconnect two issues from each other, if already connected.
# @option --inward-issue-key! Issue key of the source issue, eg: ISSUE-1
# @option --outward-issue-key! Issue key of the target issue, eg: ISSUE-2.
# @option --project! $LLM_AGENT_VAR_PROJECT <PROJECT> Jira project to operate on; e.g. PAN
unlink_issues() {
declare config_file="$HOME"/.config/.jira/"${LLM_AGENT_VAR_CONFIG}".yml
jira issue unlink \
--project "$argc_project" \
"${argc_inward_issue_key}" "${argc_outward_issue_key}" \
-c "$config_file" >> "$LLM_OUTPUT"
}
# @cmd Add a comment to an issue
# @option --issue-key! Issue key of the source issue, eg: ISSUE-1
# @option --comment-body! Body of the comment you want to add
# @option --project! $LLM_AGENT_VAR_PROJECT <PROJECT> Jira project to operate on; e.g. PAN
add_comment_to_issue() {
declare config_file="$HOME"/.config/.jira/"${LLM_AGENT_VAR_CONFIG}".yml
jira issue comment add \
--project "$argc_project" \
"${argc_issue_key}" "${argc_comment_body}" \
--no-input \
-c "$config_file" >> "$LLM_OUTPUT"
}
# @cmd Edit an existing Jira issue
# @option --issue-key! The Jira issue key, e.g. ISSUE-1
# @option --parent Link to a parent key
# @option --summary Edit summary or title
# @option --description Edit description
# @option --priority Edit priority
# @option --assignee Edit assignee (email or display name)
# @option --label Append labels
# @option --project! $LLM_AGENT_VAR_PROJECT <PROJECT> Jira project to operate on; e.g. PAN
edit_issue() {
declare config_file="$HOME"/.config/.jira/"${LLM_AGENT_VAR_CONFIG}".yml
declare -a flags=()
if [[ -n $argc_parent ]]; then
flags+=("--parent $argc_parent")
fi
if [[ -n $argc_summary ]]; then
flags+=("--summary $argc_summary")
fi
if [[ -n $argc_description ]]; then
flags+=("--body $argc_description")
fi
if [[ -n $argc_priority ]]; then
flags+=("--priority $argc_priority")
fi
if [[ -n $argc_assignee ]]; then
flags+=("--assignee $argc_assignee")
fi
if [[ -n $argc_label ]]; then
flags+=("--label $argc_label")
fi
jira issue edit \
--project "$argc_project" \
"$argc_issue_key" $(echo "${flags[*]}" | xargs) \
--no-input \
-c "$config_file" >> "$LLM_OUTPUT"
}
_issue_type_choice() {
if [[ $LLM_AGENT_VAR_CONFIG == "work" ]]; then
echo "Story"
echo "Task"
echo "Bug"
echo "Technical Debt"
echo "Sub-task"
elif [[ $LLM_AGENT_VAR_CONFIG == "sideproject" ]]; then
echo "Task"
echo "Story"
echo "Bug"
echo "Epic"
fi
}
_issue_state_choice() {
if [[ $LLM_AGENT_VAR_CONFIG == "work" ]]; then
echo "Ready for Dev"
echo "CODE REVIEW"
echo "IN PROGRESS"
echo "Backlog"
echo "Done"
echo "TESTING"
elif [[ $LLM_AGENT_VAR_CONFIG == "sideproject" ]]; then
echo "IN CLARIFICATION"
echo "NEED TO CLARIFY"
echo "READY TO WORK"
echo "RELEASE BACKLOG"
echo "REOPEN"
echo "CODE REVIEW"
echo "IN PROGRESS"
echo "IN TESTING"
echo "TO TEST"
echo "DONE"
fi
}
+17
View File
@@ -0,0 +1,17 @@
# Oracle
An AI agent specialized in high-level architecture, complex debugging, and design decisions.
This agent is designed to be delegated to by the **[Sisyphus](../sisyphus/README.md)** agent when deep reasoning, architectural advice,
or complex problem-solving is required. Sisyphus acts as the coordinator, while Oracle provides the expert analysis and
recommendations.
It can also be used as a standalone tool for design reviews and solving difficult technical challenges.
## Features
- 🏛️ System architecture and design patterns
- 🐛 Complex debugging and root cause analysis
- ⚖️ Tradeoff analysis and technology selection
- 📝 Code review and best practices advice
- 🧠 Deep reasoning for ambiguous problems
+82
View File
@@ -0,0 +1,82 @@
name: oracle
description: High-IQ advisor for architecture, debugging, and complex decisions
version: 1.0.0
temperature: 0.2
top_p: 0.95
variables:
- name: project_dir
description: Project directory for context
default: '.'
global_tools:
- fs_read.sh
- fs_grep.sh
- fs_glob.sh
- fs_ls.sh
- execute_command.sh
instructions: |
You are Oracle - a senior architect and debugger consulted for complex decisions.
## Your Role
You are READ-ONLY. You analyze, advise, and recommend. You do NOT implement.
## When You're Consulted
1. **Architecture Decisions**: Multi-system tradeoffs, design patterns, technology choices
2. **Complex Debugging**: After 2+ failed fix attempts, deep analysis needed
3. **Code Review**: Evaluating proposed designs or implementations
4. **Risk Assessment**: Security, performance, or reliability concerns
## File Reading Strategy (IMPORTANT - minimize token usage)
1. **Use grep to find relevant code** - `fs_grep --pattern "auth" --include "*.rs"` finds where things are
2. **Read only what you need** - `fs_read --path "src/main.rs" --offset 50 --limit 30` reads lines 50-79
3. **Never read entire large files** - If 500+ lines, grep first, then read the relevant section
4. **Use glob to discover files** - `fs_glob --pattern "*.rs" --path src/`
## Your Process
1. **Understand**: Use grep/glob to find relevant code, then read targeted sections
2. **Analyze**: Consider multiple angles and tradeoffs
3. **Recommend**: Provide clear, actionable advice
4. **Justify**: Explain your reasoning
## Output Format
Structure your response as:
```
## Analysis
[Your understanding of the situation]
## Recommendation
[Clear, specific advice]
## Reasoning
[Why this is the right approach]
## Risks/Considerations
[What to watch out for]
ORACLE_COMPLETE
```
## Rules
1. **Never modify files** - You advise, others implement
2. **Be thorough** - Read all relevant context before advising
3. **Be specific** - General advice isn't helpful
4. **Consider tradeoffs** - There are rarely perfect solutions
5. **Stay focused** - Answer the specific question asked
## Context
- Project: {{project_dir}}
- CWD: {{__cwd__}}
conversation_starters:
- 'Review this architecture design'
- 'Help debug this complex issue'
- 'Evaluate these implementation options'
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -eo pipefail
# shellcheck disable=SC1090
source "$LLM_PROMPT_UTILS_FILE"
source "$LLM_ROOT_DIR/agents/.shared/utils.sh"
# @env LLM_OUTPUT=/dev/stdout
# @env LLM_AGENT_VAR_PROJECT_DIR=.
# @describe Oracle agent tools for analysis and consultation (read-only)
_project_dir() {
local dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
}
# @cmd Read a file for analysis
# @option --path! Path to the file (relative to project root)
read_file() {
local project_dir
project_dir=$(_project_dir)
# shellcheck disable=SC2154
local full_path="${project_dir}/${argc_path}"
if [[ ! -f "${full_path}" ]]; then
error "File not found: ${argc_path}" >> "$LLM_OUTPUT"
return 1
fi
{
info "Reading: ${argc_path}"
echo ""
cat "${full_path}"
} >> "$LLM_OUTPUT"
}
# @cmd Get project structure and type
get_project_info() {
local project_dir
project_dir=$(_project_dir)
local project_info
project_info=$(detect_project "${project_dir}")
{
info "Project Analysis" >> "$LLM_OUTPUT"
cat <<-EOF
Type: $(echo "${project_info}" | jq -r '.type')
Build: $(echo "${project_info}" | jq -r '.build')
Test: $(echo "${project_info}" | jq -r '.test')
EOF
info "Structure:" >> "$LLM_OUTPUT"
get_tree "${project_dir}" 3
} >> "$LLM_OUTPUT"
}
# @cmd Search for patterns in the codebase
# @option --pattern! Pattern to search for
# @option --file-type Filter by extension (e.g., "rs", "py")
search_code() {
local file_type="${argc_file_type:-}"
local project_dir
project_dir=$(_project_dir)
# shellcheck disable=SC2154
info "Searching: ${argc_pattern}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local include_arg=""
if [[ -n "${file_type}" ]]; then
include_arg="--include=*.${file_type}"
fi
local results
# shellcheck disable=SC2086
results=$(grep -rn ${include_arg} "${argc_pattern}" "${project_dir}" 2>/dev/null | \
grep -v '/target/' | \
grep -v '/node_modules/' | \
grep -v '/.git/' | \
head -30) || true
if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT"
else
warn "No matches found" >> "$LLM_OUTPUT"
fi
}
# @cmd Run a read-only command for analysis (e.g., git log, cargo tree)
# @option --command! Command to run
analyze_with_command() {
local project_dir
project_dir=$(_project_dir)
local dangerous_patterns="rm |>|>>|mv |cp |chmod |chown |sudo|curl.*\\||wget.*\\|"
# shellcheck disable=SC2154
if echo "${argc_command}" | grep -qE "${dangerous_patterns}"; then
error "Command appears to modify files or be dangerous. Oracle is read-only." >> "$LLM_OUTPUT"
return 1
fi
info "Running: ${argc_command}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local output
output=$(cd "${project_dir}" && eval "${argc_command}" 2>&1) || true
echo "${output}" >> "$LLM_OUTPUT"
}
# @cmd List directory contents
# @option --path Path to list (default: project root)
list_directory() {
local dir_path="${argc_path:-.}"
local project_dir
project_dir=$(_project_dir)
local full_path="${project_dir}/${dir_path}"
if [[ ! -d "${full_path}" ]]; then
error "Directory not found: ${dir_path}" >> "$LLM_OUTPUT"
return 1
fi
{
info "Contents of: ${dir_path}"
echo ""
ls -la "${full_path}"
} >> "$LLM_OUTPUT"
}
+18
View File
@@ -0,0 +1,18 @@
# Sisyphus
The main coordinator agent for the Loki coding ecosystem, providing a powerful CLI interface for code generation and
project management similar to OpenCode, ClaudeCode, Codex, or Gemini CLI.
_Inspired by the Sisyphus and Oracle agents of OpenCode._
Sisyphus acts as the primary entry point, capable of handling complex tasks by coordinating specialized sub-agents:
- **[Coder](../coder/README.md)**: For implementation and file modifications.
- **[Explore](../explore/README.md)**: For codebase understanding and research.
- **[Oracle](../oracle/README.md)**: For architecture and complex reasoning.
## Features
- 🤖 **Coordinator**: Manages multi-step workflows and delegates to specialized agents.
- 💻 **CLI Coding**: Provides a natural language interface for writing and editing code.
- 🔄 **Task Management**: Tracks progress and context across complex operations.
- 🛠️ **Tool Integration**: Seamlessly uses system tools for building, testing, and file manipulation.
+284
View File
@@ -0,0 +1,284 @@
name: sisyphus
description: OpenCode-style orchestrator - classifies intent, delegates to specialists, tracks progress with todos
version: 1.0.0
temperature: 0.1
top_p: 0.95
agent_session: sisyphus
auto_continue: true
max_auto_continues: 25
inject_todo_instructions: true
variables:
- name: project_dir
description: Project directory to work in
default: '.'
- name: auto_confirm
description: Auto-confirm command execution
default: '1'
global_tools:
- fs_read.sh
- fs_grep.sh
- fs_glob.sh
- fs_ls.sh
- execute_command.sh
instructions: |
You are Sisyphus - an orchestrator that drives coding tasks to completion.
Your job: Classify -> Delegate -> Verify -> Complete
## Intent Classification (BEFORE every action)
| Type | Signal | Action |
|------|--------|--------|
| Trivial | Single file, known location, typo fix | Do it yourself with tools |
| Exploration | "Find X", "Where is Y", "List all Z" | Delegate to `explore` agent |
| Implementation | "Add feature", "Fix bug", "Write code" | Delegate to `coder` agent |
| Architecture/Design | See oracle triggers below | Delegate to `oracle` agent |
| Ambiguous | Unclear scope, multiple interpretations | ASK the user via `ask_user` or `ask_user_input` |
### Oracle Triggers (MUST delegate to oracle when you see these)
Delegate to `oracle` ANY time the user asks about:
- **"How should I..."** / **"What's the best way to..."** -- design/approach questions
- **"Why does X keep..."** / **"What's wrong with..."** -- complex debugging (not simple errors)
- **"Should I use X or Y?"** -- technology or pattern choices
- **"How should this be structured?"** -- architecture and organization
- **"Review this"** / **"What do you think of..."** -- code/design review
- **Tradeoff questions** -- performance vs readability, complexity vs flexibility
- **Multi-component questions** -- anything spanning 3+ files or modules
- **Vague/open-ended questions** -- "improve this", "make this better", "clean this up"
**CRITICAL**: Do NOT answer architecture/design questions yourself. You are a coordinator.
Even if you think you know the answer, oracle provides deeper, more thorough analysis.
The only exception is truly trivial questions about a single file you've already read.
## Context System (CRITICAL for multi-step tasks)
Context is shared between you and your subagents. This lets subagents know what you've learned.
**At the START of a multi-step task:**
```
start_task --goal "Description of overall task"
```
**During work** (automatically captured from delegations, or manually):
```
record_finding --source "manual" --finding "Important discovery"
```
**To see accumulated context:**
```
show_context
```
**When task is COMPLETE:**
```
end_task
```
When you delegate, subagents automatically receive all accumulated context.
## Todo System (MANDATORY for multi-step tasks)
For ANY task with 2+ steps:
1. Call `start_task` with the goal (initializes context)
2. Call `todo__init` with the goal
3. Call `todo__add` for each step BEFORE starting
4. Work through steps, calling `todo__done` IMMEDIATELY after each
5. The system auto-continues until all todos are done
6. Call `end_task` when complete (clears context)
## Delegation Pattern
When delegating, use `delegate_to_agent` with:
- agent: explore | coder | oracle
- task: Specific, atomic goal
- context: Additional context beyond what's in the shared context file
The shared context (from `start_task` and prior delegations) is automatically injected.
**CRITICAL**: After delegation, VERIFY the result before marking the todo done.
## Agent Specializations
| Agent | Use For | Characteristics |
|-------|---------|-----------------|
| explore | Find patterns, understand code, search | Read-only, returns findings |
| coder | Write/edit files, implement features | Creates/modifies files, runs builds |
| oracle | Architecture decisions, complex debugging | Advisory, high-quality reasoning |
## Workflow Examples
### Example 1: Implementation task (explore -> coder)
User: "Add a new API endpoint for user profiles"
```
1. start_task --goal "Add user profiles API endpoint"
2. todo__init --goal "Add user profiles API endpoint"
3. todo__add --task "Explore existing API patterns"
4. todo__add --task "Implement profile endpoint"
5. todo__add --task "Verify with build/test"
6. delegate_to_agent --agent explore --task "Find existing API endpoint patterns and structures"
7. todo__done --id 1
8. delegate_to_agent --agent coder --task "Create user profiles endpoint following existing patterns"
9. todo__done --id 2
10. run_build
11. run_tests
12. todo__done --id 3
13. end_task
```
### Example 2: Architecture/design question (explore -> oracle)
User: "How should I structure the authentication for this app?"
```
1. start_task --goal "Get architecture advice for authentication"
2. todo__init --goal "Get architecture advice for authentication"
3. todo__add --task "Explore current auth-related code"
4. todo__add --task "Consult oracle for architecture recommendation"
5. delegate_to_agent --agent explore --task "Find any existing auth code, middleware, user models, and session handling"
6. todo__done --id 1
7. delegate_to_agent --agent oracle --task "Recommend authentication architecture" --context "User wants auth advice. Explore found: [summarize findings]. Evaluate approaches and recommend the best one with justification."
8. todo__done --id 2
9. end_task
```
### Example 3: Vague/open-ended question (oracle directly)
User: "What do you think of this codebase structure?"
```
1. delegate_to_agent --agent oracle --task "Review the project structure and provide recommendations for improvement"
# Oracle will read files and analyze on its own
```
## Rules
1. **Always start_task first** - Initialize context before multi-step work
2. **Always classify before acting** - Don't jump into implementation
3. **Create todos for multi-step tasks** - Track your progress
4. **Delegate specialized work** - You're a coordinator, not an implementer
5. **Verify after delegation** - Don't trust blindly
6. **Mark todos done immediately** - Don't batch completions
7. **Ask when ambiguous** - Use `ask_user` or `ask_user_input` to clarify with the user interactively
8. **Get buy-in for design decisions** - Use `ask_user` to present options before implementing major changes
9. **Confirm destructive actions** - Use `ask_user_confirm` before large refactors or deletions
10. **Always end_task** - Clean up context when done
## When to Do It Yourself
- Single-file reads/writes
- Simple command execution
- Trivial changes (typos, renames)
- Quick file searches
## When to NEVER Do It Yourself
- Architecture or design questions -> ALWAYS oracle
- "How should I..." / "What's the best way to..." -> ALWAYS oracle
- Debugging after 2+ failed attempts -> ALWAYS oracle
- Code review or design review requests -> ALWAYS oracle
- Open-ended improvement questions -> ALWAYS oracle
## User Interaction (CRITICAL - get buy-in before major decisions)
You have tools to prompt the user for input. Use them to get user buy-in before making design decisions, and to clarify ambiguities interactively. **Do NOT guess when you can ask.**
### When to Prompt the User
| Situation | Tool | Example |
|-----------|------|---------|
| Multiple valid design approaches | `ask_user` | "How should we structure this?" with options |
| Confirming a destructive or major action | `ask_user_confirm` | "This will refactor 12 files. Proceed?" |
| User should pick which features/items to include | `ask_user_checkbox` | "Which endpoints should we add?" |
| Need specific input (names, paths, values) | `ask_user_input` | "What should the new module be called?" |
| Ambiguous request with different effort levels | `ask_user` | Present interpretation options |
### How to Use `ask_user` (single-select list)
Present your **recommended option first** with `(Recommended)` appended to its label:
```
ask_user --question "Which authentication strategy should we use?" \
--options "JWT with httpOnly cookies (Recommended)" \
--options "Session-based auth with Redis" \
--options "OAuth2 with third-party provider"
```
The tool renders an interactive list on the user's terminal. They navigate with arrow keys and press Enter. The selected label is returned to you.
### How to Use `ask_user_confirm` (yes/no)
```
ask_user_confirm --question "This will delete the legacy API module. Continue?"
```
Returns "User confirmed: yes" or "User confirmed: no". **Respect the answer** — if the user says no, do NOT proceed with that action.
### How to Use `ask_user_checkbox` (multi-select)
```
ask_user_checkbox --question "Which optional features should be included?" \
--options "Rate limiting" \
--options "Request logging" \
--options "CORS support" \
--options "Health check endpoint"
```
Returns a list of all selected labels. The user selects items with Space and confirms with Enter.
### How to Use `ask_user_input` (free-text)
```
ask_user_input --question "What should the database table be named?"
```
Returns whatever text the user typed.
### Design Review Pattern
For implementation tasks with design decisions, follow this pattern:
1. **Explore** the codebase to understand existing patterns
2. **Formulate** 2-3 design options based on findings
3. **Present options** to the user via `ask_user` with your recommendation marked `(Recommended)`
4. **Confirm** the chosen approach before delegating to `coder`
5. Proceed with implementation
Example flow:
```
1. delegate_to_agent --agent explore --task "Find existing API patterns"
2. ask_user --question "I found two patterns in the codebase. Which should we follow?" \
--options "REST with controller pattern in src/api/ (Recommended)" \
--options "GraphQL resolver pattern in src/graphql/"
3. ask_user_confirm --question "I'll create a new controller at src/api/profiles.rs following the REST pattern. Proceed?"
4. delegate_to_agent --agent coder --task "Create profiles controller following REST pattern"
```
### Rules for User Prompts
1. **Always include (Recommended)** on the option you think is best in `ask_user`
2. **Respect user choices** — never override or ignore a selection
3. **Don't over-prompt** — trivial decisions (variable names in small functions, formatting) don't need prompts
4. **DO prompt for**: architecture choices, file/module naming, which of multiple valid approaches to take, destructive operations, anything you're genuinely unsure about
5. **Confirm before large changes** — if a task will touch 5+ files, confirm the plan first
## Available Tools
{{__tools__}}
## Context
- Project: {{project_dir}}
- OS: {{__os__}}
- Shell: {{__shell__}}
- CWD: {{__cwd__}}
conversation_starters:
- 'Add a new feature to the project'
- 'Fix a bug in the codebase'
- 'Refactor the authentication module'
- 'Help me understand how X works'
+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env bash
set -eo pipefail
# shellcheck disable=SC1090
source "$LLM_PROMPT_UTILS_FILE"
source "$LLM_ROOT_DIR/agents/.shared/utils.sh"
export AUTO_CONFIRM=true
# @env LLM_OUTPUT=/dev/stdout
# @env LLM_AGENT_VAR_PROJECT_DIR=.
# @describe Sisyphus orchestrator tools for delegating to specialized agents
_project_dir() {
local dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
}
# @cmd Initialize context for a new task (call at the start of multi-step work)
# @option --goal! Description of the overall task/goal
start_task() {
local project_dir
project_dir=$(_project_dir)
export LLM_AGENT_VAR_PROJECT_DIR="${project_dir}"
# shellcheck disable=SC2154
init_context "${argc_goal}"
cat <<-EOF >> "$LLM_OUTPUT"
$(green "Context initialized for task: ${argc_goal}")
Context file: ${project_dir}/.loki-context
EOF
}
# @cmd Add a finding to the shared context (useful for recording discoveries)
# @option --source! Source of the finding (e.g., "manual", "explore", "coder")
# @option --finding! The finding to record
record_finding() {
local project_dir
project_dir=$(_project_dir)
export LLM_AGENT_VAR_PROJECT_DIR="${project_dir}"
# shellcheck disable=SC2154
append_context "${argc_source}" "${argc_finding}"
green "Recorded finding from ${argc_source}" >> "$LLM_OUTPUT"
}
# @cmd Show current accumulated context
show_context() {
local project_dir
project_dir=$(_project_dir)
export LLM_AGENT_VAR_PROJECT_DIR="${project_dir}"
local context
context=$(read_context)
if [[ -n "${context}" ]]; then
cat <<-EOF >> "$LLM_OUTPUT"
$(info "Current Context:")
${context}
EOF
else
warn "No context file found. Use start_task to initialize." >> "$LLM_OUTPUT"
fi
}
# @cmd Clear the context file (call when task is complete)
end_task() {
local project_dir
project_dir=$(_project_dir)
export LLM_AGENT_VAR_PROJECT_DIR="${project_dir}"
clear_context
green "Context cleared. Task complete." >> "$LLM_OUTPUT"
}
# @cmd Delegate a task to a specialized agent
# @option --agent! Agent to delegate to: explore, coder, or oracle
# @option --task! Specific task description for the agent
# @option --context Additional context (file paths, patterns, constraints)
delegate_to_agent() {
local extra_context="${argc_context:-}"
local project_dir
project_dir=$(_project_dir)
# shellcheck disable=SC2154
if [[ ! "${argc_agent}" =~ ^(explore|coder|oracle)$ ]]; then
error "Invalid agent: ${argc_agent}. Must be explore, coder, or oracle" >> "$LLM_OUTPUT"
return 1
fi
export LLM_AGENT_VAR_PROJECT_DIR="${project_dir}"
info "Delegating to ${argc_agent} agent..." >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
# shellcheck disable=SC2154
local prompt="${argc_task}"
if [[ -n "${extra_context}" ]]; then
prompt="$(printf "%s\n\nAdditional Context:\n%s\n" "${argc_task}" "${extra_context}")"
fi
cat <<-EOF >> "$LLM_OUTPUT"
$(cyan "------------------------------------------")
DELEGATING TO: ${argc_agent}
TASK: ${argc_task}
$(cyan "------------------------------------------")
EOF
local output
output=$(invoke_agent_with_summary "${argc_agent}" "${prompt}" \
--agent-variable project_dir "${project_dir}" 2>&1) || true
cat <<-EOF >> "$LLM_OUTPUT"
${output}
$(cyan "------------------------------------------")
DELEGATION COMPLETE: ${argc_agent}
$(cyan "------------------------------------------")
EOF
}
# @cmd Get project information and structure
get_project_info() {
local project_dir
project_dir=$(_project_dir)
info "Project: ${project_dir}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local project_info
project_info=$(detect_project "${project_dir}")
cat <<-EOF >> "$LLM_OUTPUT"
Type: $(echo "${project_info}" | jq -r '.type')
Build: $(echo "${project_info}" | jq -r '.build')
Test: $(echo "${project_info}" | jq -r '.test')
$(info "Directory structure:")
$(get_tree "${project_dir}" 2)
EOF
}
# @cmd Run build command for the project
run_build() {
local project_dir
project_dir=$(_project_dir)
local project_info
project_info=$(detect_project "${project_dir}")
local build_cmd
build_cmd=$(echo "${project_info}" | jq -r '.build')
if [[ -z "${build_cmd}" ]] || [[ "${build_cmd}" == "null" ]]; then
warn "No build command detected for this project" >> "$LLM_OUTPUT"
return 0
fi
info "Running: ${build_cmd}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local output
if output=$(cd "${project_dir}" && eval "${build_cmd}" 2>&1); then
green "BUILD SUCCESS" >> "$LLM_OUTPUT"
echo "${output}" >> "$LLM_OUTPUT"
return 0
else
error "BUILD FAILED" >> "$LLM_OUTPUT"
echo "${output}" >> "$LLM_OUTPUT"
return 1
fi
}
# @cmd Run tests for the project
run_tests() {
local project_dir
project_dir=$(_project_dir)
local project_info
project_info=$(detect_project "${project_dir}")
local test_cmd
test_cmd=$(echo "${project_info}" | jq -r '.test')
if [[ -z "${test_cmd}" ]] || [[ "${test_cmd}" == "null" ]]; then
warn "No test command detected for this project" >> "$LLM_OUTPUT"
return 0
fi
info "Running: ${test_cmd}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local output
if output=$(cd "${project_dir}" && eval "${test_cmd}" 2>&1); then
green "TESTS PASSED" >> "$LLM_OUTPUT"
echo "${output}" >> "$LLM_OUTPUT"
return 0
else
error "TESTS FAILED" >> "$LLM_OUTPUT"
echo "${output}" >> "$LLM_OUTPUT"
return 1
fi
}
# @cmd Quick file search in the project
# @option --pattern! File name pattern to search for (e.g., "*.rs", "config*")
search_files() {
# shellcheck disable=SC2154
local pattern="${argc_pattern}"
local project_dir
project_dir=$(_project_dir)
info "Searching for: ${pattern}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local results
results=$(search_files "${pattern}" "${project_dir}")
if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT"
else
warn "No files found matching: ${pattern}" >> "$LLM_OUTPUT"
fi
}
# @cmd Search for content in files
# @option --pattern! Text pattern to search for
# @option --file-type File extension to search in (e.g., "rs", "py")
search_content() {
local pattern="${argc_pattern}"
local file_type="${argc_file_type:-}"
local project_dir
project_dir=$(_project_dir)
info "Searching for: ${pattern}" >> "$LLM_OUTPUT"
echo "" >> "$LLM_OUTPUT"
local grep_args="-rn"
if [[ -n "${file_type}" ]]; then
grep_args="${grep_args} --include=*.${file_type}"
fi
local results
# shellcheck disable=SC2086
results=$(grep ${grep_args} "${pattern}" "${project_dir}" 2>/dev/null | \
grep -v '/target/' | grep -v '/node_modules/' | grep -v '/.git/' | \
head -30) || true
if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT"
else
warn "No matches found for: ${pattern}" >> "$LLM_OUTPUT"
fi
}
# @cmd Ask the user to select ONE option from a list. The first option should be your recommended choice — append '(Recommended)' to its label. Returns the selected option's label text.
# @option --question! The question to present to the user
# @option --options+ The list of options to present (first option = recommended, append '(Recommended)' to its label)
ask_user() {
# shellcheck disable=SC2154
local question="${argc_question}"
# shellcheck disable=SC2154
local opts=("${argc_options[@]}")
local opts_count="${#opts[@]}"
if [[ "${opts_count}" -eq 0 ]]; then
error "No options provided for ask_user" >> "$LLM_OUTPUT"
return 1
fi
info "Asking user: ${question}" >> "$LLM_OUTPUT"
local selected_index
selected_index=$(list "${question}" "${opts[@]}")
local selected_label="${opts[$selected_index]}"
cat <<-EOF >> "$LLM_OUTPUT"
User selected: ${selected_label}
EOF
}
# @cmd Ask the user a yes/no confirmation question. Returns 'yes' or 'no'.
# @option --question! The yes/no question to present to the user
ask_user_confirm() {
# shellcheck disable=SC2154
local question="${argc_question}"
info "Asking user: ${question}" >> "$LLM_OUTPUT"
local result
result=$(confirm "${question}")
if [[ "${result}" == "1" ]]; then
echo "User confirmed: yes" >> "$LLM_OUTPUT"
else
echo "User confirmed: no" >> "$LLM_OUTPUT"
fi
}
# @cmd Ask the user to select MULTIPLE options from a list (checkbox). Returns the labels of all selected items.
# @option --question! The question to present to the user
# @option --options+ The list of options the user can select from (multiple selections allowed)
ask_user_checkbox() {
# shellcheck disable=SC2154
local question="${argc_question}"
# shellcheck disable=SC2154
local opts=("${argc_options[@]}")
local opts_count="${#opts[@]}"
if [[ "${opts_count}" -eq 0 ]]; then
error "No options provided for ask_user_checkbox" >> "$LLM_OUTPUT"
return 1
fi
info "Asking user (select multiple): ${question}" >> "$LLM_OUTPUT"
local checked_indices
checked_indices=$(checkbox "${question}" "${opts[@]}")
local selected_labels=()
for idx in ${checked_indices}; do
if [[ -n "${idx}" ]] && [[ "${idx}" =~ ^[0-9]+$ ]]; then
selected_labels+=("${opts[$idx]}")
fi
done
if [[ "${#selected_labels[@]}" -eq 0 ]]; then
echo "User selected: (none)" >> "$LLM_OUTPUT"
else
echo "User selected:" >> "$LLM_OUTPUT"
for label in "${selected_labels[@]}"; do
echo " - ${label}" >> "$LLM_OUTPUT"
done
fi
}
# @cmd Ask the user for free-text input. Returns whatever the user typed.
# @option --question! The prompt/question to present to the user
ask_user_input() {
# shellcheck disable=SC2154
local question="${argc_question}"
info "Asking user: ${question}" >> "$LLM_OUTPUT"
local user_text
user_text=$(input "${question}")
cat <<-EOF >> "$LLM_OUTPUT"
User input: ${user_text}
EOF
}
+4
View File
@@ -14,6 +14,10 @@
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
}
},
"atlassian": {
"command": "npx",
"args": ["-y", "mcp-remote@0.1.13", "https://mcp.atlassian.com/v1/sse"]
},
"docker": {
"command": "uvx",
"args": ["mcp-server-docker"]
+1 -1
View File
@@ -10,5 +10,5 @@ set -e
main() {
# shellcheck disable=SC2154
cat "$argc_path" >> "$LLM_OUTPUT"
cat "$argc_path" >> "$LLM_OUTPUT" 2>&1 || echo "No such file or path: $argc_path" >> "$LLM_OUTPUT"
}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -e
# @describe Find files by glob pattern. Returns matching file paths sorted by modification time.
# Use this to discover files before reading them.
# @option --pattern! The glob pattern to match files against (e.g. "**/*.rs", "src/**/*.ts", "*.yaml")
# @option --path The directory to search in (defaults to current working directory)
# @env LLM_OUTPUT=/dev/stdout The output path
MAX_RESULTS=100
main() {
# shellcheck disable=SC2154
local glob_pattern="$argc_pattern"
local search_path="${argc_path:-.}"
if [[ ! -d "$search_path" ]]; then
echo "Error: directory not found: $search_path" >> "$LLM_OUTPUT"
return 1
fi
local results
if command -v fd &>/dev/null; then
results=$(fd --type f --glob "$glob_pattern" "$search_path" \
--exclude '.git' \
--exclude 'node_modules' \
--exclude 'target' \
--exclude 'dist' \
--exclude '__pycache__' \
--exclude 'vendor' \
--exclude '.build' \
2>/dev/null | head -n "$MAX_RESULTS") || true
else
results=$(find "$search_path" -type f -name "$glob_pattern" \
-not -path '*/.git/*' \
-not -path '*/node_modules/*' \
-not -path '*/target/*' \
-not -path '*/dist/*' \
-not -path '*/__pycache__/*' \
-not -path '*/vendor/*' \
-not -path '*/.build/*' \
2>/dev/null | head -n "$MAX_RESULTS") || true
fi
if [[ -z "$results" ]]; then
echo "No files found matching: $glob_pattern" >> "$LLM_OUTPUT"
return 0
fi
echo "$results" >> "$LLM_OUTPUT"
local count
count=$(echo "$results" | wc -l)
if [[ "$count" -ge "$MAX_RESULTS" ]]; then
printf "\n(Results limited to %s files. Use a more specific pattern.)\n" "$MAX_RESULTS" >> "$LLM_OUTPUT"
fi
}
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -e
# @describe Search file contents using regular expressions. Returns matching file paths and lines.
# Use this to find relevant code before reading files. Much faster than reading files to search.
# @option --pattern! The regex pattern to search for in file contents
# @option --path The directory to search in (defaults to current working directory)
# @option --include File pattern to filter by (e.g. "*.rs", "*.{ts,tsx}", "*.py")
# @env LLM_OUTPUT=/dev/stdout The output path
MAX_RESULTS=50
MAX_LINE_LENGTH=2000
main() {
# shellcheck disable=SC2154
local search_pattern="$argc_pattern"
local search_path="${argc_path:-.}"
local include_filter="${argc_include:-}"
if [[ ! -d "$search_path" ]]; then
echo "Error: directory not found: $search_path" >> "$LLM_OUTPUT"
return 1
fi
local grep_args=(-rn --color=never)
grep_args+=(
--exclude-dir='.git'
--exclude-dir='node_modules'
--exclude-dir='target'
--exclude-dir='dist'
--exclude-dir='build'
--exclude-dir='__pycache__'
--exclude-dir='vendor'
--exclude-dir='.build'
--exclude-dir='.next'
--exclude='*.min.js'
--exclude='*.min.css'
--exclude='*.map'
--exclude='*.lock'
--exclude='package-lock.json'
)
if [[ -n "$include_filter" ]]; then
grep_args+=("--include=$include_filter")
fi
local results
results=$(grep "${grep_args[@]}" -E "$search_pattern" "$search_path" 2>/dev/null | head -n "$MAX_RESULTS") || true
if [[ -z "$results" ]]; then
echo "No matches found for: $search_pattern" >> "$LLM_OUTPUT"
return 0
fi
echo "$results" | while IFS= read -r line; do
if [[ ${#line} -gt $MAX_LINE_LENGTH ]]; then
line="${line:0:$MAX_LINE_LENGTH}... (truncated)"
fi
echo "$line"
done >> "$LLM_OUTPUT"
local count
count=$(echo "$results" | wc -l)
if [[ "$count" -ge "$MAX_RESULTS" ]]; then
printf "\n(Results limited to %s matches. Narrow your search with --include or a more specific pattern.)\n" "$MAX_RESULTS" >> "$LLM_OUTPUT"
fi
}
+1 -1
View File
@@ -9,5 +9,5 @@ set -e
main() {
# shellcheck disable=SC2154
ls -1 "$argc_path" >> "$LLM_OUTPUT"
ls -1 "$argc_path" >> "$LLM_OUTPUT" 2>&1 || echo "No such path: $argc_path" >> "$LLM_OUTPUT"
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -e
# @describe Read a file with line numbers, offset, and limit. For directories, lists entries.
# Prefer this over fs_cat for controlled reading. Use offset/limit to read specific sections.
# Use the grep tool to find specific content before reading, then read with offset to target the relevant section.
# @option --path! The absolute path to the file or directory to read
# @option --offset The line number to start reading from (1-indexed, default: 1)
# @option --limit The maximum number of lines to read (default: 2000)
# @env LLM_OUTPUT=/dev/stdout The output path
MAX_LINE_LENGTH=2000
MAX_BYTES=51200
main() {
# shellcheck disable=SC2154
local target="$argc_path"
local offset="${argc_offset:-1}"
local limit="${argc_limit:-2000}"
if [[ ! -e "$target" ]]; then
echo "Error: path not found: $target" >> "$LLM_OUTPUT"
return 1
fi
if [[ -d "$target" ]]; then
ls -1 "$target" >> "$LLM_OUTPUT" 2>&1
return 0
fi
local total_lines file_bytes
total_lines=$(wc -l < "$target" 2>/dev/null || echo 0)
file_bytes=$(wc -c < "$target" 2>/dev/null || echo 0)
if [[ "$file_bytes" -gt "$MAX_BYTES" ]] && [[ "$offset" -eq 1 ]] && [[ "$limit" -ge 2000 ]]; then
{
echo "Warning: Large file (${file_bytes} bytes, ${total_lines} lines). Showing first ${limit} lines."
echo "Use --offset and --limit to read specific sections, or use the grep tool to find relevant lines first."
echo ""
} >> "$LLM_OUTPUT"
fi
local end_line=$((offset + limit - 1))
sed -n "${offset},${end_line}p" "$target" 2>/dev/null | {
local line_num=$offset
while IFS= read -r line; do
if [[ ${#line} -gt $MAX_LINE_LENGTH ]]; then
line="${line:0:$MAX_LINE_LENGTH}... (truncated)"
fi
printf "%d: %s\n" "$line_num" "$line"
line_num=$((line_num + 1))
done
} >> "$LLM_OUTPUT"
if [[ "$end_line" -lt "$total_lines" ]]; then
echo "" >> "$LLM_OUTPUT"
echo "(${total_lines} total lines. Use --offset $((end_line + 1)) to read more.)" >> "$LLM_OUTPUT"
fi
}
+13 -11
View File
@@ -507,12 +507,14 @@ open_link() {
guard_operation() {
if [[ -t 1 ]]; then
ans="$(confirm "${1:-Are you sure you want to continue?}")"
if [[ -z "$AUTO_CONFIRM" && -z "$LLM_AGENT_VAR_AUTO_CONFIRM" ]]; then
ans="$(confirm "${1:-Are you sure you want to continue?}")"
if [[ "$ans" == 0 ]]; then
error "Operation aborted!" 2>&1
exit 1
fi
if [[ "$ans" == 0 ]]; then
error "Operation aborted!" 2>&1
exit 1
fi
fi
fi
}
@@ -657,13 +659,13 @@ guard_path() {
path="$(_to_real_path "$1")"
confirmation_prompt="$2"
if [[ ! "$path" == "$(pwd)"* ]]; then
ans="$(confirm "$confirmation_prompt")"
if [[ ! "$path" == "$(pwd)"* && -z "$AUTO_CONFIRM" && -z "$LLM_AGENT_VAR_AUTO_CONFIRM" ]]; then
ans="$(confirm "$confirmation_prompt")"
if [[ "$ans" == 0 ]]; then
error "Operation aborted!" >&2
exit 1
fi
if [[ "$ans" == 0 ]]; then
error "Operation aborted!" >&2
exit 1
fi
fi
fi
}
+7
View File
@@ -17,6 +17,13 @@ agent_session: null # Set a session to use when starting the agent.
name: <agent-name> # Name of the agent, used in the UI and logs
description: <description> # Description of the agent, used in the UI
version: 1 # Version of the agent
# Todo System & Auto-Continuation
# These settings help smaller models handle multi-step tasks more reliably.
# See docs/TODO-SYSTEM.md for detailed documentation.
auto_continue: false # Enable automatic continuation when incomplete todos remain
max_auto_continues: 10 # Maximum number of automatic continuations before stopping
inject_todo_instructions: true # Inject the default todo tool usage instructions into the agent's system prompt
continuation_prompt: null # Custom prompt used when auto-continuing (optional; uses default if null)
mcp_servers: # Optional list of MCP servers that the agent utilizes
- github # Corresponds to the name of an MCP server in the `<loki-config-dir>/functions/mcp.json` file
global_tools: # Optional list of additional global tools to enable for the agent; i.e. not tools specific to the agent
+10 -2
View File
@@ -41,7 +41,7 @@ vault_password_file: null # Path to a file containing the password for th
# See the [Tools documentation](./docs/function-calling/TOOLS.md) for more details
function_calling: true # Enables or disables function calling (Globally).
mapping_tools: # Alias for a tool or toolset
fs: 'fs_cat,fs_ls,fs_mkdir,fs_rm,fs_write'
fs: 'fs_cat,fs_ls,fs_mkdir,fs_rm,fs_write,fs_read,fs_glob,fs_grep'
enabled_tools: null # Which tools to enable by default. (e.g. 'fs,web_search_loki')
visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools')
# - demo_py.py
@@ -53,6 +53,9 @@ visible_tools: # Which tools are visible to be compiled (and a
# - fetch_url_via_jina.sh
- fs_cat.sh
- fs_ls.sh
# - fs_read.sh
# - fs_glob.sh
# - fs_grep.sh
# - fs_mkdir.sh
# - fs_patch.sh
# - fs_write.sh
@@ -92,7 +95,7 @@ rag_reranker_model: null # Specifies the reranker model used for sorting
rag_top_k: 5 # Specifies the number of documents to retrieve for answering queries
rag_chunk_size: null # Defines the size of chunks for document processing in characters
rag_chunk_overlap: null # Defines the overlap between chunks
# Defines the query structure using variables like __CONTEXT__ and __INPUT__ to tailor searches to specific needs
# Defines the query structure using variables like __CONTEXT__, __SOURCES__, and __INPUT__ to tailor searches to specific needs
rag_template: |
Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags)
@@ -100,6 +103,10 @@ rag_template: |
__CONTEXT__
</context>
<sources>
__SOURCES__
</sources>
<rules>
- If you don't know, just say so.
- If you are not sure, ask for clarification.
@@ -107,6 +114,7 @@ rag_template: |
- If the context appears unreadable or of poor quality, tell the user then answer as best as you can.
- If the answer is not in the context but you think you know the answer, explain that to the user then answer with your own knowledge.
- Answer directly and without using xml tags.
- When using information from the context, cite the relevant source from the <sources> section.
</rules>
<user_query>
+54 -1
View File
@@ -34,6 +34,7 @@ If you're looking for more example agents, refer to the [built-in agents](../ass
- [Python-Based Agent Tools](#python-based-agent-tools)
- [Bash-Based Agent Tools](#bash-based-agent-tools)
- [5. Conversation Starters](#5-conversation-starters)
- [6. Todo System & Auto-Continuation](#6-todo-system--auto-continuation)
- [Built-In Agents](#built-in-agents)
<!--toc:end-->
@@ -81,6 +82,11 @@ global_tools: # Optional list of additional global tools
- web_search
- fs
- python
# Todo System & Auto-Continuation (see "Todo System & Auto-Continuation" section below)
auto_continue: false # Enable automatic continuation when incomplete todos remain
max_auto_continues: 10 # Maximum continuation attempts before stopping
inject_todo_instructions: true # Inject todo tool instructions into system prompt
continuation_prompt: null # Custom prompt for continuations (optional)
```
As mentioned previously: Agents utilize function calling to extend a model's capabilities. However, agents operate in
@@ -421,10 +427,57 @@ conversation_starters:
![Example Conversation Starters](./images/agents/conversation-starters.gif)
## 6. Todo System & Auto-Continuation
Loki includes a built-in task tracking system designed to improve the reliability of agents, especially when using
smaller language models. The Todo System helps models:
- Break complex tasks into manageable steps
- Track progress through multi-step workflows
- Automatically continue work until all tasks are complete
### Quick Configuration
```yaml
# agents/my-agent/config.yaml
auto_continue: true # Enable auto-continuation
max_auto_continues: 10 # Max continuation attempts
inject_todo_instructions: true # Include the default todo instructions into prompt
```
### How It Works
1. When `inject_todo_instructions` is enabled, agents receive instructions on using four built-in tools:
- `todo__init`: Initialize a todo list with a goal
- `todo__add`: Add a task to the list
- `todo__done`: Mark a task complete
- `todo__list`: View current todo state
These instructions are a reasonable default that detail how to use Loki's To-Do System. If you wish,
you can disable the injection of the default instructions and specify your own instructions for how
to use the To-Do System into your main `instructions` for the agent.
2. When `auto_continue` is enabled and the model stops with incomplete tasks, Loki automatically sends a
continuation prompt with the current todo state, nudging the model to continue working.
3. This continues until all tasks are done or `max_auto_continues` is reached.
### When to Use
- Multistep tasks where the model might lose track
- Smaller models that need more structure
- Workflows requiring guaranteed completion of all steps
For complete documentation including all configuration options, tool details, and best practices, see the
[Todo System Guide](./TODO-SYSTEM.md).
## Built-In Agents
Loki comes packaged with some useful built-in agents:
* `coder`: An agent to assist you with all your coding tasks
* `demo`: An example agent to use for reference when learning to create your own agents
* `explore`: An agent designed to help you explore and understand your codebase
* `jira-helper`: An agent that assists you with all your Jira-related tasks
* `oracle`: An agent for high-level architecture, design decisions, and complex debugging
* `sisyphus`: A powerhouse agent for writing complex code and acting as a natural language interface for your codebase (similar to ClaudeCode, Gemini CLI, Codex, or OpenCode)
* `sql`: A universal SQL agent that enables you to talk to any relational database in natural language
+7 -1
View File
@@ -17,6 +17,7 @@ loki --info | grep 'config_dir' | awk '{print $2}'
- [Files and Directory Related Variables](#files-and-directory-related-variables)
- [Agent Related Variables](#agent-related-variables)
- [Logging Related Variables](#logging-related-variables)
- [Miscellaneous Variables](#miscellaneous-variables)
<!--toc:end-->
---
@@ -84,7 +85,7 @@ You can also customize the location of full agent configurations using the follo
| Environment Variable | Description |
|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------|
| `<AGENT_NAME>_CONFIG_FILE | Customize the location of the agent's configuration file; e.g. `SQL_CONFIG_FILE` |
| `<AGENT_NAME>_CONFIG_FILE` | Customize the location of the agent's configuration file; e.g. `SQL_CONFIG_FILE` |
| `<AGENT_NAME>_MODEL` | Customize the `model` used for the agent; e.g `SQL_MODEL` |
| `<AGENT_NAME>_TEMPERATURE` | Customize the `temperature` used for the agent; e.g. `SQL_TEMPERATURE` |
| `<AGENT_NAME>_TOP_P` | Customize the `top_p` used for the agent; e.g. `SQL_TOP_P` |
@@ -104,3 +105,8 @@ The following variables can be used to change the log level of Loki or the locat
**Pro-Tip:** You can always tail the Loki logs using the `--tail-logs` flag. If you need to disable color output, you
can also pass the `--disable-log-colors` flag as well.
## Miscellaneous Variables
| Environment Variable | Description | Default Value |
|----------------------|--------------------------------------------------------------------------------------------------|---------------|
| `AUTO_CONFIRM` | Bypass all `guard_*` checks in the bash prompt helpers; useful for agent composition and routing | |
+11 -3
View File
@@ -265,12 +265,14 @@ When you use RAG in Loki, after Loki performs the lookup for relevant chunks of
will add the retrieved text chunks as context to your query before sending it to the model. The format of this context
is determined by the `rag_template` setting in your global Loki configuration file.
This template utilizes two placeholders:
This template utilizes three placeholders:
* `__INPUT__`: The user's actual query
* `__CONTEXT__`: The context retrieved from RAG
* `__SOURCES__`: A numbered list of the source file paths or URLs that the retrieved context came from
These placeholders are replaced with the corresponding values into the template and make up what's actually passed to
the model at query-time.
the model at query-time. The `__SOURCES__` placeholder enables the model to cite which documents its answer is based on,
which is especially useful when building knowledge-base assistants that need to provide verifiable references.
The default template that Loki uses is the following:
@@ -281,6 +283,10 @@ Answer the query based on the context while respecting the rules. (user query, s
__CONTEXT__
</context>
<sources>
__SOURCES__
</sources>
<rules>
- If you don't know, just say so.
- If you are not sure, ask for clarification.
@@ -288,6 +294,7 @@ __CONTEXT__
- If the context appears unreadable or of poor quality, tell the user then answer as best as you can.
- If the answer is not in the context but you think you know the answer, explain that to the user then answer with your own knowledge.
- Answer directly and without using xml tags.
- When using information from the context, cite the relevant source from the <sources> section.
</rules>
<user_query>
@@ -296,4 +303,5 @@ __INPUT__
```
You can customize this template by specifying the `rag_template` setting in your global Loki configuration file. Your
template *must* include both the `__INPUT__` and `__CONTEXT__` placeholders in order for it to be valid.
template *must* include both the `__INPUT__` and `__CONTEXT__` placeholders in order for it to be valid. The
`__SOURCES__` placeholder is optional. If it is omitted, source references will not be included in the prompt.
+234
View File
@@ -0,0 +1,234 @@
# Todo System
Loki's Todo System is a built-in task tracking feature designed to improve the reliability and effectiveness of LLM agents,
especially smaller models. It provides structured task management that helps models:
- Break complex tasks into manageable steps
- Track progress through multistep workflows
- Automatically continue work until all tasks are complete
- Avoid forgetting steps or losing context
![Todo System Example](./images/agents/todo-system.png)
## Quick Links
<!--toc:start-->
- [Why Use the Todo System?](#why-use-the-todo-system)
- [How It Works](#how-it-works)
- [Configuration Options](#configuration-options)
- [Available Tools](#available-tools)
- [Auto-Continuation](#auto-continuation)
- [Best Practices](#best-practices)
- [Example Workflow](#example-workflow)
- [Troubleshooting](#troubleshooting)
<!--toc:end-->
## Why Use the Todo System?
Smaller language models often struggle with:
- **Context drift**: Forgetting earlier steps in a multi-step task
- **Incomplete execution**: Stopping before all work is done
- **Lack of structure**: Jumping between tasks without clear organization
The Loki Todo System addresses these issues by giving the model explicit tools to plan, track, and verify task completion.
The system automatically prompts the model to continue when incomplete tasks remain, ensuring work gets finished.
## How It Works
1. **Planning Phase**: The model initializes a todo list with a goal and adds individual tasks
2. **Execution Phase**: The model works through tasks, marking each done immediately after completion
3. **Continuation Phase**: If incomplete tasks remain, the system automatically prompts the model to continue
4. **Completion**: When all tasks are marked done, the workflow ends naturally
The todo state is preserved across the conversation (and any compressions), and injected into continuation prompts,
keeping the model focused on remaining work.
## Configuration Options
The Todo System is configured per-agent in `<loki-config-dir>/agents/<agent-name>/config.yaml`:
| Setting | Type | Default | Description |
|----------------------------|---------|-------------|---------------------------------------------------------------------------------|
| `auto_continue` | boolean | `false` | Enable the To-Do system for automatic continuation when incomplete todos remain |
| `max_auto_continues` | integer | `10` | Maximum number of automatic continuations before stopping |
| `inject_todo_instructions` | boolean | `true` | Inject the default todo tool usage instructions into the agent's system prompt |
| `continuation_prompt` | string | (see below) | Custom prompt used when auto-continuing |
### Example Configuration
```yaml
# agents/my-agent/config.yaml
model: openai:gpt-4o
auto_continue: true # Enable auto-continuation
max_auto_continues: 15 # Allow up to 15 automatic continuations
inject_todo_instructions: true # Include todo instructions in system prompt
continuation_prompt: | # Optional: customize the continuation prompt
[CONTINUE]
You have unfinished tasks. Proceed with the next pending item.
Do not explain; just execute.
```
### Default Continuation Prompt
If `continuation_prompt` is not specified, the following default is used:
```
[SYSTEM REMINDER - TODO CONTINUATION]
You have incomplete tasks in your todo list. Continue with the next pending item.
Call tools immediately. Do not explain what you will do.
```
## Available Tools
When `inject_todo_instructions` is enabled (the default), agents have access to four built-in todo management tools:
### `todo__init`
Initialize a new todo list with a goal. Clears any existing todos.
**Parameters:**
- `goal` (string, required): The overall goal to achieve when all todos are completed
**Example:**
```json
{"goal": "Refactor the authentication module"}
```
### `todo__add`
Add a new todo item to the list.
**Parameters:**
- `task` (string, required): Description of the todo task
**Example:**
```json
{"task": "Extract password validation into separate function"}
```
**Returns:** The assigned task ID
### `todo__done`
Mark a todo item as done by its ID.
**Parameters:**
- `id` (integer, required): The ID of the todo item to mark as done
**Example:**
```json
{"id": 1}
```
### `todo__list`
Display the current todo list with status of each item.
**Parameters:** None
**Returns:** The full todo list with goal, progress, and item statuses
## Auto-Continuation
When `auto_continue` is enabled, Loki automatically sends a continuation prompt if:
1. The agent's response completes (model stops generating)
2. There are incomplete tasks in the todo list
3. The continuation count hasn't exceeded `max_auto_continues`
4. The response isn't identical to the previous continuation (prevents loops)
### What Gets Injected
Each continuation prompt includes:
- The continuation prompt text (default or custom)
- The current todo list state showing:
- The goal
- Progress (e.g., "3/5 completed")
- Each task with status (✓ done, ○ pending)
**Example continuation context:**
```
[SYSTEM REMINDER - TODO CONTINUATION]
You have incomplete tasks in your todo list. Continue with the next pending item.
Call tools immediately. Do not explain what you will do.
Goal: Refactor the authentication module
Progress: 2/4 completed
✓ 1. Extract password validation into separate function
✓ 2. Add unit tests for password validation
○ 3. Update login handler to use new validation
○ 4. Update registration handler to use new validation
```
### Visual Feedback
During auto-continuation, you'll see a message in your terminal:
```
📋 Auto-continuing (3/10): 2 incomplete todo(s) remain
```
## Best Practices
### For Agent Developers
1. **Enable for complex workflows**: Use `auto_continue: true` for agents that handle multistep tasks
2. **Set reasonable limits**: Adjust `max_auto_continues` based on typical task complexity
3. **Customize the prompt**: If your agent needs specific continuation behavior, set a custom `continuation_prompt`
### For Model Behavior
The injected instructions tell models to:
1. **Always create a todo list before starting work**: This ensures planning happens upfront
2. **Mark each task done immediately after finishing**: Don't batch completions
3. **Add all planned steps before starting**: Complete planning before execution
### When to Use vs. Skip
**Use the Todo System when:**
- Tasks have 3+ distinct steps
- The model might lose track of progress
- You want guaranteed completion of all steps
- Working with smaller/less capable models
**Skip the Todo System when:**
- Single-step, simple tasks
- Interactive Q&A sessions
- The overhead of task tracking isn't worth it
## Example Workflow
Here's how a typical todo-driven workflow looks:
**User prompt:** "Add input validation to the user registration form"
**Model actions:**
```
1. todo__init(goal="Add input validation to user registration form")
2. todo__add(task="Analyze current registration form fields")
3. todo__add(task="Create validation rules for email field")
4. todo__add(task="Create validation rules for password field")
5. todo__add(task="Implement client-side validation")
6. todo__add(task="Add server-side validation")
7. todo__add(task="Write tests for validation logic")
```
**Model executes first task, then:**
```
8. todo__done(id=1)
9. [Proceeds with task 2...]
10. todo__done(id=2)
...
```
**If model stops with incomplete tasks:**
- System automatically sends continuation prompt
- Model sees remaining tasks and continues
- Repeats until all tasks are done or max continuations reached
## Troubleshooting
### Model Not Using Todo Tools
- Verify `inject_todo_instructions: true` in your agent config
- Check that the agent is properly loaded (not just a role)
- Some models may need explicit prompting to use the tools
### Too Many Continuations
- Lower `max_auto_continues` to a reasonable limit
- Check if the model is creating new tasks without completing old ones
- Ensure tasks are appropriately scoped (not too granular)
### Continuation Loop
The system detects when a model's response is identical to its previous continuation response and stops
automatically. If you're seeing loops:
- The model may be stuck; check if a task is impossible to complete
- Consider adjusting the `continuation_prompt` to be more directive
---
## Additional Docs
- [Agents](./AGENTS.md) - Full agent configuration guide
- [Function Calling](./function-calling/TOOLS.md) - How tools work in Loki
- [Sessions](./SESSIONS.md) - How conversation state is managed
+1 -1
View File
@@ -114,7 +114,7 @@ At the time of writing, the following files support Loki secret injection:
|-------------------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------|
| `config.yaml` | The main Loki configuration file | Cannot use secret injection on the `vault_password_file` field |
| `functions/mcp.json` | The MCP server configuration file | |
| `<agent>/tools.<py/sh>` | Tool files for agents | Specific configuration and only supported for Agents, not all global tools ([see below](#environment-variable-secret-injection-in-agents) |
| `<agent>/tools.<py/sh>` | Tool files for agents | Specific configuration and only supported for Agents, not all global tools ([see below](#environment-variable-secret-injection-in-agents)) |
Note that all paths are relative to the Loki configuration directory. The directory varies by system, so you can find yours by
+6 -2
View File
@@ -207,7 +207,9 @@ open_link https://www.google.com
```
### guard_operation
Prompt for permission to run an operation
Prompt for permission to run an operation.
Can be disabled by setting the environment variable `AUTO_CONFIRM`.
**Example:**
```bash
@@ -216,7 +218,9 @@ _run_sql
```
### guard_path
Prompt for permission to perform path operations
Prompt for permission to perform path operations.
Can be disabled by setting the environment variable `AUTO_CONFIRM`.
**Example:***
```bash
+1 -3
View File
@@ -83,9 +83,7 @@ enabled_mcp_servers: null # Which MCP servers to enable by default (e.g.
```
A special note about `enabled_mcp_servers`: a user can set this to `all` to enable all configured MCP servers in the
`functions/mcp.json` configuration. However, **this should be used with caution**. When there is a significant number
of configured MCP servers, enabling all MCP servers may overwhelm the context length of a model, and quickly exceed
token limits.
`functions/mcp.json` configuration.
(See the [Configuration Example](../../config.example.yaml) file for an example global configuration with all options.)
+51
View File
@@ -16,6 +16,10 @@ loki --info | grep functions_dir | awk '{print $2}'
- [Enabling/Disabling Global Tools](#enablingdisabling-global-tools)
- [Role Configuration](#role-configuration)
- [Agent Configuration](#agent-configuration)
- [Tool Error Handling](#tool-error-handling)
- [Native/Shell Tool Errors](#nativeshell-tool-errors)
- [MCP Errors](#mcp-tool-errors)
- [Why Tool Error Handling Is Important](#why-this-matters)
<!--toc:end-->
---
@@ -34,6 +38,9 @@ be enabled/disabled can be found in the [Configuration](#configuration) section
| [`fetch_url_via_curl.sh`](../../assets/functions/tools/fetch_url_via_curl.sh) | Extract the content from a given URL using cURL. | 🔴 |
| [`fetch_url_via_jina.sh`](../../assets/functions/tools/fetch_url_via_jina.sh) | Extract the content from a given URL using Jina. | 🔴 |
| [`fs_cat.sh`](../../assets/functions/tools/fs_cat.sh) | Read the contents of a file at the specified path. | 🟢 |
| [`fs_read.sh`](../../assets/functions/tools/fs_read.sh) | Controlled reading of the contents of a file at the specified path with line numbers, offset, and limit to read specific sections. | 🟢 |
| [`fs_glob.sh`](../../assets/functions/tools/fs_glob.sh) | Find files by glob pattern. Returns matching file paths sorted by modification time. | 🟢 |
| [`fs_grep.sh`](../../assets/functions/tools/fs_grep.sh) | Search file contents using regular expressions. Returns matching file paths and lines. | 🟢 |
| [`fs_ls.sh`](../../assets/functions/tools/fs_ls.sh) | List all files and directories at the specified path. | 🟢 |
| [`fs_mkdir.sh`](../../assets/functions/tools/fs_mkdir.sh) | Create a new directory at the specified path. | 🔴 |
| [`fs_patch.sh`](../../assets/functions/tools/fs_patch.sh) | Apply a patch to a file at the specified path. <br>This can be used to edit a file without having to rewrite the whole file. | 🔴 |
@@ -137,3 +144,47 @@ The values for `mapping_tools` are inherited from the [global configuration](#gl
For more information about agents, refer to the [Agents](../AGENTS.md) documentation.
For a full example configuration for an agent, see the [Agent Configuration Example](../../config.agent.example.yaml) file.
---
## Tool Error Handling
When tools fail, Loki captures error information and passes it back to the model so it can diagnose issues and
potentially retry or adjust its approach.
### Native/Shell Tool Errors
When a shell-based tool exits with a non-zero exit code, the model receives:
```json
{
"tool_call_error": "Tool call 'my_tool' exited with code 1",
"stderr": "Error: file not found: config.json"
}
```
The `stderr` field contains the actual error output from the tool, giving the model context about what went wrong.
If the tool produces no stderr output, only the `tool_call_error` field is included.
**Note:** Tool stdout streams to your terminal in real-time so you can see progress. Only stderr is captured for
error reporting.
### MCP Tool Errors
When an MCP (Model Context Protocol) tool invocation fails due to connection issues, timeouts, or server errors,
the model receives:
```json
{
"tool_call_error": "MCP tool invocation failed: connection refused"
}
```
This allows the model to understand that an external service failed and take appropriate action (retry, use an
alternative approach, or inform the user).
### Why This Matters
Without proper error propagation, models would only know that "something went wrong" without understanding *what*
went wrong. By including stderr output and detailed error messages, models can:
- Diagnose the root cause of failures
- Suggest fixes (e.g., "the file doesn't exist, should I create it?")
- Retry with corrected parameters
- Fall back to alternative approaches when appropriate
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+25
View File
@@ -0,0 +1,25 @@
# List all recipes
default:
@just --list
# Run all tests
[group: 'test']
test:
cargo test --all
# See what linter errors and warnings are unaddressed
[group: 'style']
lint:
cargo clippy --all
# Run Rustfmt against all source files
[group: 'style']
fmt:
cargo fmt --all
# Build the project for the current system architecture
# (Gets stored at ./target/[debug|release]/loki)
[group: 'build']
[arg('build_type', pattern="debug|release")]
build build_type='debug':
@cargo build {{ if build_type == "release" { "--release" } else { "" } }}
+244 -217
View File
@@ -3,6 +3,20 @@
# - https://platform.openai.com/docs/api-reference/chat
- provider: openai
models:
- name: gpt-5.1
max_input_tokens: 400000
max_output_tokens: 128000
input_price: 1.25
output_price: 10
supports_vision: true
supports_function_calling: true
- name: gpt-5.1-chat-latest
max_input_tokens: 400000
max_output_tokens: 128000
input_price: 1.25
output_price: 10
supports_vision: true
supports_function_calling: true
- name: gpt-5
max_input_tokens: 400000
max_output_tokens: 128000
@@ -31,13 +45,6 @@
output_price: 0.4
supports_vision: true
supports_function_calling: true
- name: gpt-5-codex
max_input_tokens: 400000
max_output_tokens: 128000
input_price: 1.25
output_price: 10
supports_vision: true
supports_function_calling: true
- name: gpt-4.1
max_input_tokens: 1047576
max_output_tokens: 32768
@@ -259,6 +266,56 @@
thinking:
type: enabled
budget_tokens: 16000
- name: claude-haiku-4-5-20251001
max_input_tokens: 200000
max_output_tokens: 8192
require_max_tokens: true
input_price: 1
output_price: 5
supports_vision: true
supports_function_calling: true
- name: claude-haiku-4-5-20251001:thinking
real_name: claude-haiku-4-5-20251001
max_input_tokens: 200000
max_output_tokens: 24000
require_max_tokens: true
input_price: 1
output_price: 5
supports_vision: true
supports_function_calling: true
patch:
body:
temperature: null
top_p: null
thinking:
type: enabled
budget_tokens: 16000
- name: claude-opus-4-5-20251101
type: chat
max_input_tokens: 200000
input_price: 15.0
output_price: 75.0
max_output_tokens: 8192
require_max_tokens: true
supports_vision: true
supports_function_calling: true
- name: claude-opus-4-5-20251101:thinking
type: chat
real_name: claude-opus-4-5-20251101
max_input_tokens: 200000
input_price: 15.0
output_price: 75.0
patch:
body:
temperature: null
top_p: null
thinking:
type: enabled
budget_tokens: 16000
max_output_tokens: 24000
require_max_tokens: true
supports_vision: true
supports_function_calling: true
- name: claude-opus-4-1-20250805
max_input_tokens: 200000
max_output_tokens: 8192
@@ -602,6 +659,14 @@
# - https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/gemini
- provider: vertexai
models:
- name: gemini-3-pro-preview
hipaa_safe: true
max_input_tokens: 1048576
max_output_tokens: 65536
input_price: 0
output_price: 0
supports_vision: true
supports_function_calling: true
- name: gemini-2.5-flash
max_input_tokens: 1048576
max_output_tokens: 65536
@@ -660,6 +725,29 @@
thinking:
type: enabled
budget_tokens: 16000
- name: claude-haiku-4-5@20251001
max_input_tokens: 200000
max_output_tokens: 8192
require_max_tokens: true
input_price: 1
output_price: 5
supports_vision: true
supports_function_calling: true
- name: claude-haiku-4-5@20251001:thinking
real_name: claude-haiku-4-5@20251001
max_input_tokens: 200000
max_output_tokens: 24000
require_max_tokens: true
input_price: 1
output_price: 5
supports_vision: true
patch:
body:
temperature: null
top_p: null
thinking:
type: enabled
budget_tokens: 16000
- name: claude-opus-4-1@20250805
max_input_tokens: 200000
max_output_tokens: 8192
@@ -817,6 +905,31 @@
thinking:
type: enabled
budget_tokens: 16000
- name: us.anthropic.claude-haiku-4-5-20251001-v1:0
max_input_tokens: 200000
max_output_tokens: 8192
require_max_tokens: true
input_price: 1
output_price: 5
supports_vision: true
supports_function_calling: true
- name: us.anthropic.claude-haiku-4-5-20251001-v1:0:thinking
real_name: us.anthropic.claude-haiku-4-5-20251001-v1:0
max_input_tokens: 200000
max_output_tokens: 24000
require_max_tokens: true
input_price: 1
output_price: 5
supports_vision: true
patch:
body:
inferenceConfig:
temperature: null
topP: null
additionalModelRequestFields:
thinking:
type: enabled
budget_tokens: 16000
- name: us.anthropic.claude-opus-4-1-20250805-v1:0
max_input_tokens: 200000
max_output_tokens: 8192
@@ -1004,6 +1117,12 @@
require_max_tokens: true
input_price: 0
output_price: 0
- name: '@cf/qwen/qwen3-30b-a3b-fp8'
max_input_tokens: 131072
max_output_tokens: 2048
require_max_tokens: true
input_price: 0
output_price: 0
- name: '@cf/qwen/qwen2.5-coder-32b-instruct'
max_input_tokens: 131072
max_output_tokens: 2048
@@ -1030,8 +1149,8 @@
max_batch_size: 100
# Links:
# - https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Wm9cvy6rl
# - https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Qm9cw2s7m
# - https://cloud.baidu.com/doc/qianfan/s/rmh4stp0j
# - https://cloud.baidu.com/doc/qianfan/s/wmh4sv6ya
- provider: ernie
models:
- name: ernie-4.5-turbo-128k
@@ -1043,8 +1162,12 @@
input_price: 0.42
output_price: 1.26
supports_vision: true
- name: ernie-x1-turbo-32k
max_input_tokens: 32768
- name: ernie-5.0-thinking-preview
max_input_tokens: 131072
input_price: 1.4
output_price: 5.6
- name: ernie-x1.1-preview
max_input_tokens: 65536
input_price: 0.14
output_price: 0.56
- name: bge-large-zh
@@ -1064,75 +1187,31 @@
max_input_tokens: 1024
input_price: 0.07
# Links:
# - https://help.aliyun.com/zh/model-studio/getting-started/models
# - https://help.aliyun.com/zh/model-studio/developer-reference/use-qwen-by-calling-api
- provider: qianwen
models:
- name: qwen-max-latest
max_input_tokens: 32678
max_output_tokens: 8192
input_price: 1.6
output_price: 6.4
supports_function_calling: true
- name: qwen-plus-latest
max_input_tokens: 131072
max_output_tokens: 8192
input_price: 0.112
output_price: 0.28
supports_function_calling: true
- name: qwen-turbo-latest
max_input_tokens: 1000000
max_output_tokens: 8192
input_price: 0.042
output_price: 0.084
supports_function_calling: true
- name: qwen-long
max_input_tokens: 1000000
input_price: 0.07
output_price: 0.28
- name: qwen-omni-turbo-latest
max_input_tokens: 32768
max_output_tokens: 2048
supports_vision: true
- name: qwen-coder-plus-latest
max_input_tokens: 131072
max_output_tokens: 8192
input_price: 0.49
output_price: 0.98
- name: qwen-coder-turbo-latest
max_input_tokens: 131072
max_output_tokens: 8192
input_price: 0.28
output_price: 0.84
- name: qwen-vl-max-latest
max_input_tokens: 30720
max_output_tokens: 2048
input_price: 0.42
output_price: 1.26
supports_vision: true
- name: qwen-vl-plus-latest
max_input_tokens: 30000
max_output_tokens: 2048
input_price: 0.21
output_price: 0.63
supports_vision: true
- name: qwen3-max
max_input_tokens: 262144
input_price: 2.1
output_price: 8.4
supports_function_calling: true
- name: qwen-plus
max_input_tokens: 131072
supports_function_calling: true
- name: qwen-flash
max_input_tokens: 1000000
supports_function_calling: true
- name: qwen3-vl-plus
max_input_tokens: 262144
input_price: 0.42
output_price: 4.2
supports_vision: true
- name: qwen3-max-preview
- name: qwen3-vl-flash
max_input_tokens: 262144
max_output_tokens: 32768
input_price: 1.4
output_price: 5.6
supports_function_calling: true
supports_vision: true
- name: qwen-coder-plus
max_input_tokens: 1000000
- name: qwen-coder-flash
max_input_tokens: 1000000
- name: qwen3-next-80b-a3b-instruct
max_input_tokens: 131072
input_price: 0.14
@@ -1160,6 +1239,16 @@
max_input_tokens: 131072
input_price: 0.105
output_price: 1.05
- name: qwen3-vl-32b-instruct
max_input_tokens: 131072
input_price: 0.28
output_price: 1.12
supports_vision: true
- name: qwen3-vl-8b-instruct
max_input_tokens: 131072
input_price: 0.07
output_price: 0.28
supports_vision: true
- name: qwen3-coder-480b-a35b-instruct
max_input_tokens: 262144
input_price: 1.26
@@ -1168,32 +1257,10 @@
max_input_tokens: 262144
input_price: 0.315
output_price: 1.26
- name: qwen2.5-72b-instruct
max_input_tokens: 129024
max_output_tokens: 8192
input_price: 0.56
output_price: 1.68
supports_function_calling: true
- name: qwen2.5-vl-72b-instruct
max_input_tokens: 129024
max_output_tokens: 8192
input_price: 2.24
output_price: 6.72
supports_vision: true
- name: qwen2.5-coder-32b-instruct
max_input_tokens: 129024
max_output_tokens: 8192
input_price: 0.49
output_price: 0.98
supports_function_calling: true
- name: deepseek-v3.1
- name: deepseek-v3.2-exp
max_input_tokens: 131072
input_price: 0.28
output_price: 1.12
- name: deepseek-r1-0528
max_input_tokens: 65536
input_price: 0.28
output_price: 1.12
output_price: 0.42
- name: text-embedding-v4
type: embedding
input_price: 0.1
@@ -1247,10 +1314,10 @@
# - https://platform.moonshot.cn/docs/api/chat#%E5%85%AC%E5%BC%80%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%9C%B0%E5%9D%80
- provider: moonshot
models:
- name: kimi-latest
max_input_tokens: 131072
input_price: 1.4
output_price: 4.2
- name: kimi-k2-turbo-preview
max_input_tokens: 262144
input_price: 1.12
output_price: 8.12
supports_vision: true
supports_function_calling: true
- name: kimi-k2-0905-preview
@@ -1259,16 +1326,15 @@
output_price: 2.24
supports_vision: true
supports_function_calling: true
- name: kimi-k2-turbo-preview
max_input_tokens: 131072
- name: kimi-k2-thinking-turbo
max_input_tokens: 262144
input_price: 1.12
output_price: 4.48
output_price: 8.12
supports_vision: true
supports_function_calling: true
- name: kimi-thinking-preview
max_input_tokens: 131072
input_price: 28
output_price: 28
- name: kimi-k2-thinking
max_input_tokens: 262144
input_price: 0.56
output_price: 2.24
supports_vision: true
# Links:
@@ -1293,7 +1359,7 @@
# - https://open.bigmodel.cn/dev/api#glm-4
- provider: zhipuai
models:
- name: glm-4.5
- name: glm-4.6
max_input_tokens: 202752
input_price: 0.28
output_price: 1.12
@@ -1353,25 +1419,35 @@
input_price: 0.112
# Links:
# - https://platform.minimaxi.com/document/pricing
# - https://platform.minimaxi.com/docs/guides/pricing
# - https://platform.minimaxi.com/document/ChatCompletion%20v2
- provider: minimax
models:
- name: minimax-text-01
max_input_tokens: 1000192
input_price: 0.14
output_price: 1.12
supports_vision: true
- name: minimax-m1
max_input_tokens: 131072
input_price: 0.112
output_price: 1.12
- name: minimax-m2
max_input_tokens: 204800
input_price: 0.294
output_price: 1.176
supports_function_calling: true
# Links:
# - https://openrouter.ai/models
# - https://openrouter.ai/docs/api-reference/chat-completion
- provider: openrouter
models:
- name: openai/gpt-5.1
max_input_tokens: 400000
max_output_tokens: 128000
input_price: 1.25
output_price: 10
supports_vision: true
supports_function_calling: true
- name: openai/gpt-5.1-chat
max_input_tokens: 400000
max_output_tokens: 128000
input_price: 1.25
output_price: 10
supports_vision: true
supports_function_calling: true
- name: openai/gpt-5
max_input_tokens: 400000
max_output_tokens: 128000
@@ -1400,13 +1476,6 @@
output_price: 0.4
supports_vision: true
supports_function_calling: true
- name: openai/gpt-5-codex
max_input_tokens: 400000
max_output_tokens: 128000
input_price: 1.25
output_price: 10
supports_vision: true
supports_function_calling: true
- name: openai/gpt-4.1
max_input_tokens: 1047576
max_output_tokens: 32768
@@ -1563,6 +1632,14 @@
output_price: 15
supports_vision: true
supports_function_calling: true
- name: anthropic/claude-haiku-4.5
max_input_tokens: 200000
max_output_tokens: 8192
require_max_tokens: true
input_price: 1
output_price: 5
supports_vision: true
supports_function_calling: true
- name: anthropic/claude-opus-4.1
max_input_tokens: 200000
max_output_tokens: 8192
@@ -1696,11 +1773,10 @@
patch:
body:
include_reasoning: true
- name: qwen/qwen-max
max_input_tokens: 32768
max_output_tokens: 8192
input_price: 1.6
output_price: 6.4
- name: qwen/qwen3-max
max_input_tokens: 262144
input_price: 1.2
output_price: 6
supports_function_calling: true
- name: qwen/qwen-plus
max_input_tokens: 131072
@@ -1708,22 +1784,6 @@
input_price: 0.4
output_price: 1.2
supports_function_calling: true
- name: qwen/qwen-turbo
max_input_tokens: 1000000
max_output_tokens: 8192
input_price: 0.05
output_price: 0.2
supports_function_calling: true
- name: qwen/qwen-vl-plus
max_input_tokens: 7500
input_price: 0.21
output_price: 0.63
supports_vision: true
- name: qwen/qwen3-max
max_input_tokens: 262144
input_price: 1.2
output_price: 6
supports_function_calling: true
- name: qwen/qwen3-next-80b-a3b-instruct
max_input_tokens: 262144
input_price: 0.1
@@ -1733,7 +1793,7 @@
max_input_tokens: 262144
input_price: 0.1
output_price: 0.8
- name: qwen/qwen3-235b-a22b-2507
- name: qwen/qwen5-235b-a22b-2507 # Qwen3 235B A22B Instruct 2507
max_input_tokens: 262144
input_price: 0.12
output_price: 0.59
@@ -1750,6 +1810,16 @@
max_input_tokens: 262144
input_price: 0.071
output_price: 0.285
- name: qwen/qwen3-vl-32b-instruct
max_input_tokens: 262144
input_price: 0.35
output_price: 1.1
supports_vision: true
- name: qwen/qwen3-vl-8b-instruct
max_input_tokens: 262144
input_price: 0.08
output_price: 0.50
supports_vision: true
- name: qwen/qwen3-coder-plus
max_input_tokens: 128000
input_price: 1
@@ -1760,30 +1830,26 @@
input_price: 0.3
output_price: 1.5
supports_function_calling: true
- name: qwen/qwen3-coder # Qwen3 Coder 480B A35B
max_input_tokens: 262144
input_price: 0.22
output_price: 0.95
supports_function_calling: true
- name: qwen/qwen3-coder-30b-a3b-instruct
max_input_tokens: 262144
input_price: 0.052
output_price: 0.207
supports_function_calling: true
- name: qwen/qwen-2.5-72b-instruct
max_input_tokens: 131072
input_price: 0.35
output_price: 0.4
supports_function_calling: true
- name: qwen/qwen2.5-vl-72b-instruct
max_input_tokens: 32000
input_price: 0.7
output_price: 0.7
supports_vision: true
- name: qwen/qwen-2.5-coder-32b-instruct
max_input_tokens: 32768
input_price: 0.18
output_price: 0.18
- name: moonshotai/kimi-k2-0905
max_input_tokens: 262144
input_price: 0.296
output_price: 1.185
supports_function_calling: true
- name: moonshotai/kimi-k2-thinking
max_input_tokens: 262144
input_price: 0.45
output_price: 2.35
supports_function_calling: true
- name: moonshotai/kimi-dev-72b
max_input_tokens: 131072
input_price: 0.29
@@ -1804,6 +1870,11 @@
input_price: 0.2
output_price: 1.5
supports_function_calling: true
- name: amazon/nova-premier-v1
max_input_tokens: 1000000
input_price: 2.5
output_price: 12.5
supports_vision: true
- name: amazon/nova-pro-v1
max_input_tokens: 300000
max_output_tokens: 5120
@@ -1850,29 +1921,15 @@
patch:
body:
include_reasoning: true
- name: minimax/minimax-01
max_input_tokens: 1000192
input_price: 0.2
output_price: 1.1
- name: minimax/minimax-m2
max_input_tokens: 196608
input_price: 0.15
output_price: 0.45
- name: z-ai/glm-4.6
max_input_tokens: 202752
input_price: 0.5
output_price: 1.75
supports_function_calling: true
- name: z-ai/glm-4.5
max_input_tokens: 131072
input_price: 0.2
output_price: 0.2
supports_function_calling: true
- name: z-ai/glm-4.5-air
max_input_tokens: 131072
input_price: 0.2
output_price: 1.1
- name: z-ai/glm-4.5v
max_input_tokens: 65536
input_price: 0.5
output_price: 1.7
supports_vision: true
# Links:
# - https://github.com/marketplace?type=models
@@ -2068,10 +2125,6 @@
input_price: 0.08
output_price: 0.3
supports_vision: true
- name: meta-llama/Llama-3.3-70B-Instruct
max_input_tokens: 131072
input_price: 0.23
output_price: 0.40
- name: Qwen/Qwen3-Next-80B-A3B-Instruct
max_input_tokens: 262144
input_price: 0.14
@@ -2100,27 +2153,15 @@
input_price: 0.07
output_price: 0.27
supports_function_calling: true
- name: Qwen/Qwen3-235B-A22B
max_input_tokens: 40960
input_price: 0.15
output_price: 0.6
- name: Qwen/Qwen3-30B-A3B
max_input_tokens: 40960
input_price: 0.1
output_price: 0.3
- name: Qwen/Qwen3-32B
max_input_tokens: 40960
input_price: 0.1
output_price: 0.3
- name: Qwen/Qwen2.5-72B-Instruct
max_input_tokens: 32768
input_price: 0.23
output_price: 0.40
supports_function_calling: true
- name: Qwen/Qwen2.5-Coder-32B-Instruct
max_input_tokens: 32768
input_price: 0.07
output_price: 0.16
- name: Qwen/Qwen3-VL-8B-Instruct
max_input_tokens: 262144
input_price: 0.18
output_price: 0.69
supports_vision: true
- name: deepseek-ai/DeepSeek-V3.2-Exp
max_input_tokens: 163840
input_price: 0.27
@@ -2145,35 +2186,21 @@
max_input_tokens: 32768
input_price: 0.06
output_price: 0.12
- name: mistralai/Devstral-Small-2507
max_input_tokens: 131072
input_price: 0.07
output_price: 0.28
- name: moonshotai/Kimi-K2-Instruct-0905
max_input_tokens: 262144
input_price: 0.5
output_price: 2.0
supports_function_calling: true
- name: moonshotai/Kimi-K2-Thinking
max_input_tokens: 262144
input_price: 0.55
output_price: 2.5
supports_function_calling: true
- name: zai-org/GLM-4.6
max_input_tokens: 202752
input_price: 0.6
output_price: 1.9
supports_function_calling: true
- name: zai-org/GLM-4.5
max_input_tokens: 131072
input_price: 0.55
output_price: 2.0
supports_function_calling: true
- name: zai-org/GLM-4.5-Air
max_input_tokens: 131072
input_price: 0.2
output_price: 1.1
supports_function_calling: true
- name: zai-org/GLM-4.5V
max_input_tokens: 65536
input_price: 0.5
output_price: 1.7
supports_vision: true
- name: BAAI/bge-large-en-v1.5
type: embedding
input_price: 0.01
@@ -2271,4 +2298,4 @@
- name: rerank-2-lite
type: reranker
max_input_tokens: 8000
input_price: 0.02
input_price: 0.02
+2 -2
View File
@@ -234,7 +234,7 @@ async fn chat_completions_streaming(
}
let arguments: Value =
function_arguments.parse().with_context(|| {
format!("Tool call '{function_name}' have non-JSON arguments '{function_arguments}'")
format!("Tool call '{function_name}' has non-JSON arguments '{function_arguments}'")
})?;
handler.tool_call(ToolCall::new(
function_name.clone(),
@@ -272,7 +272,7 @@ async fn chat_completions_streaming(
function_arguments = String::from("{}");
}
let arguments: Value = function_arguments.parse().with_context(|| {
format!("Tool call '{function_name}' have non-JSON arguments '{function_arguments}'")
format!("Tool call '{function_name}' has non-JSON arguments '{function_arguments}'")
})?;
handler.tool_call(ToolCall::new(
function_name.clone(),
+8 -5
View File
@@ -93,10 +93,13 @@ pub async fn claude_chat_completions_streaming(
data["content_block"]["id"].as_str(),
) {
if !function_name.is_empty() {
let arguments: Value =
let arguments: Value = if function_arguments.is_empty() {
json!({})
} else {
function_arguments.parse().with_context(|| {
format!("Tool call '{function_name}' have non-JSON arguments '{function_arguments}'")
})?;
format!("Tool call '{function_name}' has non-JSON arguments '{function_arguments}'")
})?
};
handler.tool_call(ToolCall::new(
function_name.clone(),
arguments,
@@ -134,7 +137,7 @@ pub async fn claude_chat_completions_streaming(
json!({})
} else {
function_arguments.parse().with_context(|| {
format!("Tool call '{function_name}' have non-JSON arguments '{function_arguments}'")
format!("Tool call '{function_name}' has non-JSON arguments '{function_arguments}'")
})?
};
handler.tool_call(ToolCall::new(
@@ -286,7 +289,7 @@ pub fn claude_build_chat_completions_body(
body["tools"] = functions
.iter()
.map(|v| {
if v.parameters.type_value.is_none() {
if v.parameters.is_empty_properties() {
json!({
"name": v.name,
"description": v.description,
+2 -2
View File
@@ -167,7 +167,7 @@ async fn chat_completions_streaming(
"tool-call-end" => {
if !function_name.is_empty() {
let arguments: Value = function_arguments.parse().with_context(|| {
format!("Tool call '{function_name}' have non-JSON arguments '{function_arguments}'")
format!("Tool call '{function_name}' has non-JSON arguments '{function_arguments}'")
})?;
handler.tool_call(ToolCall::new(
function_name.clone(),
@@ -230,7 +230,7 @@ fn extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {
call["id"].as_str(),
) {
let arguments: Value = arguments.parse().with_context(|| {
format!("Tool call '{name}' have non-JSON arguments '{arguments}'")
format!("Tool call '{name}' has non-JSON arguments '{arguments}'")
})?;
tool_calls.push(ToolCall::new(
name.to_string(),
+14 -8
View File
@@ -433,10 +433,13 @@ pub async fn call_chat_completions(
client.global_config().read().print_markdown(&text)?;
}
}
Ok((
text,
eval_tool_calls(client.global_config(), tool_calls).await?,
))
let tool_results = eval_tool_calls(client.global_config(), tool_calls).await?;
if let Some(tracker) = client.global_config().write().tool_call_tracker.as_mut() {
tool_results
.iter()
.for_each(|res| tracker.record_call(res.call.clone()));
}
Ok((text, tool_results))
}
Err(err) => Err(err),
}
@@ -467,10 +470,13 @@ pub async fn call_chat_completions_streaming(
if !text.is_empty() && !text.ends_with('\n') {
println!();
}
Ok((
text,
eval_tool_calls(client.global_config(), tool_calls).await?,
))
let tool_results = eval_tool_calls(client.global_config(), tool_calls).await?;
if let Some(tracker) = client.global_config().write().tool_call_tracker.as_mut() {
tool_results
.iter()
.for_each(|res| tracker.record_call(res.call.clone()));
}
Ok((text, tool_results))
}
Err(err) => {
if !text.is_empty() {
+1 -1
View File
@@ -228,7 +228,7 @@ macro_rules! config_get_fn {
std::env::var(&env_name)
.ok()
.or_else(|| self.config.$field_name.clone())
.ok_or_else(|| anyhow::anyhow!("Miss '{}'", stringify!($field_name)))
.ok_or_else(|| anyhow::anyhow!("Missing '{}'", stringify!($field_name)))
}
};
}
+2 -2
View File
@@ -164,7 +164,7 @@ pub async fn openai_chat_completions_streaming(
function_arguments = String::from("{}");
}
let arguments: Value = function_arguments.parse().with_context(|| {
format!("Tool call '{function_name}' have non-JSON arguments '{function_arguments}'")
format!("Tool call '{function_name}' has non-JSON arguments '{function_arguments}'")
})?;
handler.tool_call(ToolCall::new(
function_name.clone(),
@@ -370,7 +370,7 @@ pub fn openai_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
call["id"].as_str(),
) {
let arguments: Value = arguments.parse().with_context(|| {
format!("Tool call '{name}' have non-JSON arguments '{arguments}'")
format!("Tool call '{name}' has non-JSON arguments '{arguments}'")
})?;
tool_calls.push(ToolCall::new(
name.to_string(),
+153 -2
View File
@@ -13,6 +13,9 @@ pub struct SseHandler {
abort_signal: AbortSignal,
buffer: String,
tool_calls: Vec<ToolCall>,
last_tool_calls: Vec<ToolCall>,
max_call_repeats: usize,
call_repeat_chain_len: usize,
}
impl SseHandler {
@@ -22,11 +25,13 @@ impl SseHandler {
abort_signal,
buffer: String::new(),
tool_calls: Vec::new(),
last_tool_calls: Vec::new(),
max_call_repeats: 2,
call_repeat_chain_len: 3,
}
}
pub fn text(&mut self, text: &str) -> Result<()> {
// debug!("HandleText: {}", text);
if text.is_empty() {
return Ok(());
}
@@ -45,7 +50,6 @@ impl SseHandler {
}
pub fn done(&mut self) {
// debug!("HandleDone");
let ret = self.sender.send(SseEvent::Done);
if ret.is_err() {
if self.abort_signal.aborted() {
@@ -56,14 +60,114 @@ impl SseHandler {
}
pub fn tool_call(&mut self, call: ToolCall) -> Result<()> {
if self.is_call_loop(&call) {
let loop_message = self.create_loop_detection_message(&call);
return Err(anyhow!(loop_message));
}
if self.last_tool_calls.len() == self.call_repeat_chain_len * self.max_call_repeats {
self.last_tool_calls.remove(0);
}
self.last_tool_calls.push(call.clone());
self.tool_calls.push(call);
Ok(())
}
fn is_call_loop(&self, new_call: &ToolCall) -> bool {
if self.last_tool_calls.len() < self.call_repeat_chain_len {
return false;
}
if let Some(last_call) = self.last_tool_calls.last()
&& self.calls_match(last_call, new_call)
{
let mut repeat_count = 1;
for i in (0..self.last_tool_calls.len()).rev() {
if i == 0 {
break;
}
if self.calls_match(&self.last_tool_calls[i - 1], &self.last_tool_calls[i]) {
repeat_count += 1;
if repeat_count >= self.max_call_repeats {
return true;
}
} else {
break;
}
}
}
let chain_start = self
.last_tool_calls
.len()
.saturating_sub(self.call_repeat_chain_len);
let chain = &self.last_tool_calls[chain_start..];
if chain.len() == self.call_repeat_chain_len {
let mut is_repeating = true;
for i in 0..chain.len() - 1 {
if !self.calls_match(&chain[i], &chain[i + 1]) {
is_repeating = false;
break;
}
}
if is_repeating && self.calls_match(&chain[chain.len() - 1], new_call) {
return true;
}
}
false
}
fn calls_match(&self, call1: &ToolCall, call2: &ToolCall) -> bool {
call1.name == call2.name && call1.arguments == call2.arguments
}
fn create_loop_detection_message(&self, new_call: &ToolCall) -> String {
let mut message = String::from("⚠️ Call loop detected! ⚠️");
message.push_str(&format!(
"The call '{}' with arguments '{}' is repeating.\n",
new_call.name, new_call.arguments
));
if self.last_tool_calls.len() >= self.call_repeat_chain_len {
let chain_start = self
.last_tool_calls
.len()
.saturating_sub(self.call_repeat_chain_len);
let chain = &self.last_tool_calls[chain_start..];
message.push_str("The following sequence of calls is repeating:\n");
for (i, call) in chain.iter().enumerate() {
message.push_str(&format!(
" {}. {} with arguments {}\n",
i + 1,
call.name,
call.arguments
));
}
}
message.push_str("\nPlease move on to the next task in your sequence using the last output you got from the call or chain you are trying to re-execute. ");
message.push_str(
"Consider using different parameters or a different approach to avoid this loop.",
);
message
}
pub fn abort(&self) -> AbortSignal {
self.abort_signal.clone()
}
#[cfg(test)]
pub fn last_tool_calls(&self) -> &[ToolCall] {
&self.last_tool_calls
}
pub fn take(self) -> (String, Vec<ToolCall>) {
let Self {
buffer, tool_calls, ..
@@ -239,6 +343,53 @@ mod tests {
use bytes::Bytes;
use futures_util::stream;
use rand::Rng;
use serde_json::json;
#[test]
fn test_last_tool_calls_ring_buffer() {
let (sender, _) = tokio::sync::mpsc::unbounded_channel();
let abort_signal = crate::utils::create_abort_signal();
let mut handler = SseHandler::new(sender, abort_signal);
for i in 0..15 {
let call = ToolCall::new(format!("test_function_{}", i), json!({"param": i}), None);
handler.tool_call(call.clone()).unwrap();
}
let lt_len = handler.call_repeat_chain_len * handler.max_call_repeats;
assert_eq!(handler.last_tool_calls().len(), lt_len);
assert_eq!(
handler.last_tool_calls()[lt_len - 1].name,
"test_function_14"
);
assert_eq!(
handler.last_tool_calls()[0].name,
format!("test_function_{}", 14 - lt_len + 1)
);
}
#[test]
fn test_call_loop_detection() {
let (sender, _) = tokio::sync::mpsc::unbounded_channel();
let abort_signal = crate::utils::create_abort_signal();
let mut handler = SseHandler::new(sender, abort_signal);
handler.max_call_repeats = 2;
handler.call_repeat_chain_len = 3;
let call = ToolCall::new("test_function_loop".to_string(), json!({"param": 1}), None);
for _ in 0..3 {
handler.tool_call(call.clone()).unwrap();
}
let result = handler.tool_call(call.clone());
assert!(result.is_err());
let error_message = result.unwrap_err().to_string();
assert!(error_message.contains("Call loop detected!"));
assert!(error_message.contains("test_function_loop"));
}
fn split_chunks(text: &str) -> Vec<Vec<u8>> {
let mut rng = rand::rng();
+22 -4
View File
@@ -219,7 +219,14 @@ pub async fn gemini_chat_completions_streaming(
part["functionCall"]["name"].as_str(),
part["functionCall"]["args"].as_object(),
) {
handler.tool_call(ToolCall::new(name.to_string(), json!(args), None))?;
let thought_signature = part["thoughtSignature"]
.as_str()
.or_else(|| part["thought_signature"].as_str())
.map(|s| s.to_string());
handler.tool_call(
ToolCall::new(name.to_string(), json!(args), None)
.with_thought_signature(thought_signature),
)?;
}
}
} else if let Some("SAFETY") = data["promptFeedback"]["blockReason"]
@@ -280,7 +287,14 @@ fn gemini_extract_chat_completions_text(data: &Value) -> Result<ChatCompletionsO
part["functionCall"]["name"].as_str(),
part["functionCall"]["args"].as_object(),
) {
tool_calls.push(ToolCall::new(name.to_string(), json!(args), None));
let thought_signature = part["thoughtSignature"]
.as_str()
.or_else(|| part["thought_signature"].as_str())
.map(|s| s.to_string());
tool_calls.push(
ToolCall::new(name.to_string(), json!(args), None)
.with_thought_signature(thought_signature),
);
}
}
}
@@ -347,12 +361,16 @@ pub fn gemini_build_chat_completions_body(
},
MessageContent::ToolCalls(MessageContentToolCalls { tool_results, .. }) => {
let model_parts: Vec<Value> = tool_results.iter().map(|tool_result| {
json!({
let mut part = json!({
"functionCall": {
"name": tool_result.call.name,
"args": tool_result.call.arguments,
}
})
});
if let Some(sig) = &tool_result.call.thought_signature {
part["thoughtSignature"] = json!(sig);
}
part
}).collect();
let function_parts: Vec<Value> = tool_results.into_iter().map(|tool_result| {
json!({
+127 -5
View File
@@ -1,3 +1,4 @@
use super::todo::TodoList;
use super::*;
use crate::{
@@ -14,6 +15,18 @@ use serde::{Deserialize, Serialize};
use std::{ffi::OsStr, path::Path};
const DEFAULT_AGENT_NAME: &str = "rag";
const DEFAULT_TODO_INSTRUCTIONS: &str = "\
\n## Task Tracking\n\
You have built-in task tracking tools. Use them to track your progress:\n\
- `todo__init`: Initialize a todo list with a goal. Call this at the start of every multi-step task.\n\
- `todo__add`: Add individual tasks. Add all planned steps before starting work.\n\
- `todo__done`: Mark a task done by id. Call this immediately after completing each step.\n\
- `todo__list`: Show the current todo list.\n\
\n\
RULES:\n\
- Always create a todo list before starting work.\n\
- Mark each task done as soon as you finish it; do not batch.\n\
- If you stop with incomplete tasks, the system will automatically prompt you to continue.";
pub type AgentVariables = IndexMap<String, String>;
@@ -33,6 +46,9 @@ pub struct Agent {
rag: Option<Arc<Rag>>,
model: Model,
vault: GlobalVault,
todo_list: TodoList,
continuation_count: usize,
last_continuation_response: Option<String>,
}
impl Agent {
@@ -188,6 +204,10 @@ impl Agent {
None
};
if agent_config.auto_continue {
functions.append_todo_functions();
}
Ok(Self {
name: name.to_string(),
config: agent_config,
@@ -199,11 +219,15 @@ impl Agent {
rag,
model,
vault: Arc::clone(&config.read().vault),
todo_list: TodoList::default(),
continuation_count: 0,
last_continuation_response: None,
})
}
pub fn init_agent_variables(
agent_variables: &[AgentVariable],
pre_set_variables: Option<&AgentVariables>,
no_interaction: bool,
) -> Result<AgentVariables> {
let mut output = IndexMap::new();
@@ -214,6 +238,10 @@ impl Agent {
let mut unset_variables = vec![];
for agent_variable in agent_variables {
let key = agent_variable.name.clone();
if let Some(value) = pre_set_variables.and_then(|v| v.get(&key)) {
output.insert(key, value.clone());
continue;
}
if let Some(value) = agent_variable.default.clone() {
output.insert(key, value);
continue;
@@ -280,7 +308,7 @@ impl Agent {
}
pub fn banner(&self) -> String {
self.config.banner()
self.config.banner(&self.conversation_starters())
}
pub fn name(&self) -> &str {
@@ -295,8 +323,12 @@ impl Agent {
self.rag.clone()
}
pub fn conversation_starters(&self) -> &[String] {
&self.config.conversation_starters
pub fn conversation_starters(&self) -> Vec<String> {
self.config
.conversation_starters
.iter()
.map(|starter| self.interpolate_text(starter))
.collect()
}
pub fn interpolated_instructions(&self) -> String {
@@ -305,6 +337,16 @@ impl Agent {
.clone()
.or_else(|| self.shared_dynamic_instructions.clone())
.unwrap_or_else(|| self.config.instructions.clone());
if self.config.auto_continue && self.config.inject_todo_instructions {
output.push_str(DEFAULT_TODO_INSTRUCTIONS);
}
self.interpolate_text(&output)
}
fn interpolate_text(&self, text: &str) -> String {
let mut output = text.to_string();
for (k, v) in self.variables() {
output = output.replace(&format!("{{{{{k}}}}}"), v)
}
@@ -362,6 +404,69 @@ impl Agent {
self.session_dynamic_instructions = None;
}
pub fn auto_continue_enabled(&self) -> bool {
self.config.auto_continue
}
pub fn max_auto_continues(&self) -> usize {
self.config.max_auto_continues
}
pub fn continuation_count(&self) -> usize {
self.continuation_count
}
pub fn increment_continuation(&mut self) {
self.continuation_count += 1;
}
pub fn reset_continuation(&mut self) {
self.continuation_count = 0;
self.last_continuation_response = None;
}
pub fn is_stale_response(&self, response: &str) -> bool {
self.last_continuation_response
.as_ref()
.is_some_and(|last| last == response)
}
pub fn set_last_continuation_response(&mut self, response: String) {
self.last_continuation_response = Some(response);
}
pub fn todo_list(&self) -> &TodoList {
&self.todo_list
}
pub fn init_todo_list(&mut self, goal: &str) {
self.todo_list = TodoList::new(goal);
}
pub fn add_todo(&mut self, task: &str) -> usize {
self.todo_list.add(task)
}
pub fn mark_todo_done(&mut self, id: usize) -> bool {
self.todo_list.mark_done(id)
}
pub fn continuation_prompt(&self) -> String {
self.config.continuation_prompt.clone().unwrap_or_else(|| {
formatdoc! {"
[SYSTEM REMINDER - TODO CONTINUATION]
You have incomplete tasks. Rules:
1. BEFORE marking a todo done: verify the work compiles/works. No premature completion.
2. If a todo is broad (e.g. \"implement X and implement Y\"): break it into specific subtasks FIRST using todo__add, then work on those.\n\
3. Each todo should be atomic and be \"single responsibility\" - completable in one focused action.
4. Continue with the next pending item now. Call tools immediately."}
})
}
pub fn compression_threshold(&self) -> Option<usize> {
self.config.compression_threshold
}
pub fn is_dynamic_instructions(&self) -> bool {
self.config.dynamic_instructions
}
@@ -484,6 +589,14 @@ pub struct AgentConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_session: Option<String>,
#[serde(default)]
pub auto_continue: bool,
#[serde(default = "default_max_auto_continues")]
pub max_auto_continues: usize,
#[serde(default = "default_true")]
pub inject_todo_instructions: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub compression_threshold: Option<usize>,
#[serde(default)]
pub description: String,
#[serde(default)]
pub version: String,
@@ -491,6 +604,8 @@ pub struct AgentConfig {
pub mcp_servers: Vec<String>,
#[serde(default)]
pub global_tools: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub continuation_prompt: Option<String>,
#[serde(default)]
pub instructions: String,
#[serde(default)]
@@ -503,6 +618,14 @@ pub struct AgentConfig {
pub documents: Vec<String>,
}
fn default_max_auto_continues() -> usize {
10
}
fn default_true() -> bool {
true
}
impl AgentConfig {
pub fn load(path: &Path) -> Result<Self> {
let contents = read_to_string(path)
@@ -550,12 +673,11 @@ impl AgentConfig {
}
}
fn banner(&self) -> String {
fn banner(&self, conversation_starters: &[String]) -> String {
let AgentConfig {
name,
description,
version,
conversation_starters,
..
} = self;
let starters = if conversation_starters.is_empty() {
+58 -19
View File
@@ -3,6 +3,7 @@ mod input;
mod macros;
mod role;
mod session;
pub(crate) mod todo;
pub use self::agent::{Agent, AgentVariables, complete_agent_variables, list_agents};
pub use self::input::Input;
@@ -17,14 +18,15 @@ use crate::client::{
ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
ProviderModels, create_client_config, list_client_types, list_models,
};
use crate::function::{FunctionDeclaration, Functions, ToolResult};
use crate::function::{FunctionDeclaration, Functions, ToolCallTracker, ToolResult};
use crate::rag::Rag;
use crate::render::{MarkdownRender, RenderOptions};
use crate::utils::*;
use crate::config::macros::Macro;
use crate::mcp::{
MCP_INVOKE_META_FUNCTION_NAME_PREFIX, MCP_LIST_META_FUNCTION_NAME_PREFIX, McpRegistry,
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpRegistry,
};
use crate::vault::{GlobalVault, Vault, create_vault_password_file, interpolate_secrets};
use anyhow::{Context, Result, anyhow, bail};
@@ -94,6 +96,10 @@ const RAG_TEMPLATE: &str = r#"Answer the query based on the context while respec
__CONTEXT__
</context>
<sources>
__SOURCES__
</sources>
<rules>
- If you don't know, just say so.
- If you are not sure, ask for clarification.
@@ -101,6 +107,7 @@ __CONTEXT__
- If the context appears unreadable or of poor quality, tell the user then answer as best as you can.
- If the answer is not in the context but you think you know the answer, explain that to the user then answer with your own knowledge.
- Answer directly and without using xml tags.
- When using information from the context, cite the relevant source from the <sources> section.
</rules>
<user_query>
@@ -198,6 +205,8 @@ pub struct Config {
pub rag: Option<Arc<Rag>>,
#[serde(skip)]
pub agent: Option<Agent>,
#[serde(skip)]
pub(crate) tool_call_tracker: Option<ToolCallTracker>,
}
impl Default for Config {
@@ -270,6 +279,7 @@ impl Default for Config {
session: None,
rag: None,
agent: None,
tool_call_tracker: Some(ToolCallTracker::default()),
}
}
}
@@ -799,7 +809,7 @@ impl Config {
|| s == "all"
}) {
bail!(
"Some of the specified MCP servers in 'enabled_mcp_servers' are configured. Please check your MCP server configuration."
"Some of the specified MCP servers in 'enabled_mcp_servers' are not fully configured. Please check your MCP server configuration."
);
}
}
@@ -1569,8 +1579,18 @@ impl Config {
.summary_context_prompt
.clone()
.unwrap_or_else(|| SUMMARY_CONTEXT_PROMPT.into());
let todo_prefix = config
.read()
.agent
.as_ref()
.map(|agent| agent.todo_list())
.filter(|todos| !todos.is_empty())
.map(|todos| format!("[ACTIVE TODO LIST]\n{}\n\n", todos.render_for_model()))
.unwrap_or_default();
if let Some(session) = config.write().session.as_mut() {
session.compress(format!("{summary_context_prompt}{summary}"));
session.compress(format!("{todo_prefix}{summary_context_prompt}{summary}"));
}
config.write().discontinuous_last_message();
Ok(())
@@ -1741,10 +1761,10 @@ impl Config {
abort_signal: AbortSignal,
) -> Result<String> {
let (reranker_model, top_k) = rag.get_config();
let (embeddings, ids) = rag
let (embeddings, sources, ids) = rag
.search(text, top_k, reranker_model.as_deref(), abort_signal)
.await?;
let text = config.read().rag_template(&embeddings, text);
let text = config.read().rag_template(&embeddings, &sources, text);
rag.set_last_sources(&ids);
Ok(text)
}
@@ -1766,7 +1786,7 @@ impl Config {
}
}
pub fn rag_template(&self, embeddings: &str, text: &str) -> String {
pub fn rag_template(&self, embeddings: &str, sources: &str, text: &str) -> String {
if embeddings.is_empty() {
return text.to_string();
}
@@ -1774,6 +1794,7 @@ impl Config {
.as_deref()
.unwrap_or(RAG_TEMPLATE)
.replace("__CONTEXT__", embeddings)
.replace("__SOURCES__", sources)
.replace("__INPUT__", text)
}
@@ -1972,7 +1993,8 @@ impl Config {
.iter()
.filter(|v| {
!v.name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX)
&& !v.name.starts_with(MCP_LIST_META_FUNCTION_NAME_PREFIX)
&& !v.name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX)
&& !v.name.starts_with(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX)
})
.map(|v| v.name.to_string())
.collect();
@@ -2015,7 +2037,8 @@ impl Config {
.into_iter()
.filter(|v| {
!v.name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX)
&& !v.name.starts_with(MCP_LIST_META_FUNCTION_NAME_PREFIX)
&& !v.name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX)
&& !v.name.starts_with(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX)
})
.collect();
let tool_names: HashSet<String> = agent_functions
@@ -2051,7 +2074,8 @@ impl Config {
.iter()
.filter(|v| {
v.name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX)
|| v.name.starts_with(MCP_LIST_META_FUNCTION_NAME_PREFIX)
|| v.name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX)
|| v.name.starts_with(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX)
})
.map(|v| v.name.to_string())
.collect();
@@ -2062,8 +2086,10 @@ impl Config {
let item = item.trim();
let item_invoke_name =
format!("{}_{item}", MCP_INVOKE_META_FUNCTION_NAME_PREFIX);
let item_list_name =
format!("{}_{item}", MCP_LIST_META_FUNCTION_NAME_PREFIX);
let item_search_name =
format!("{}_{item}", MCP_SEARCH_META_FUNCTION_NAME_PREFIX);
let item_describe_name =
format!("{}_{item}", MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX);
if let Some(values) = self.mapping_mcp_servers.get(item) {
server_names.extend(
values
@@ -2077,7 +2103,12 @@ impl Config {
),
format!(
"{}_{}",
MCP_LIST_META_FUNCTION_NAME_PREFIX,
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
v.to_string()
),
format!(
"{}_{}",
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX,
v.to_string()
),
]
@@ -2086,7 +2117,8 @@ impl Config {
)
} else if mcp_declaration_names.contains(&item_invoke_name) {
server_names.insert(item_invoke_name);
server_names.insert(item_list_name);
server_names.insert(item_search_name);
server_names.insert(item_describe_name);
}
}
}
@@ -2112,7 +2144,8 @@ impl Config {
.into_iter()
.filter(|v| {
v.name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX)
|| v.name.starts_with(MCP_LIST_META_FUNCTION_NAME_PREFIX)
|| v.name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX)
|| v.name.starts_with(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX)
})
.collect();
let tool_names: HashSet<String> = agent_functions
@@ -2594,8 +2627,11 @@ impl Config {
None => return Ok(()),
};
if !agent.defined_variables().is_empty() && agent.shared_variables().is_empty() {
let new_variables =
Agent::init_agent_variables(agent.defined_variables(), self.info_flag)?;
let new_variables = Agent::init_agent_variables(
agent.defined_variables(),
self.agent_variables.as_ref(),
self.info_flag,
)?;
agent.set_shared_variables(new_variables);
}
if !self.info_flag {
@@ -2613,8 +2649,11 @@ impl Config {
let shared_variables = agent.shared_variables().clone();
let session_variables =
if !agent.defined_variables().is_empty() && shared_variables.is_empty() {
let new_variables =
Agent::init_agent_variables(agent.defined_variables(), self.info_flag)?;
let new_variables = Agent::init_agent_variables(
agent.defined_variables(),
self.agent_variables.as_ref(),
self.info_flag,
)?;
agent.set_shared_variables(new_variables.clone());
new_variables
} else {
+3
View File
@@ -299,6 +299,9 @@ impl Session {
self.role_prompt = agent.interpolated_instructions();
self.agent_variables = agent.variables().clone();
self.agent_instructions = self.role_prompt.clone();
if let Some(threshold) = agent.compression_threshold() {
self.set_compression_threshold(Some(threshold));
}
}
pub fn agent_variables(&self) -> &AgentVariables {
+165
View File
@@ -0,0 +1,165 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TodoStatus {
Pending,
Done,
}
impl TodoStatus {
fn icon(&self) -> &'static str {
match self {
TodoStatus::Pending => "",
TodoStatus::Done => "",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoItem {
pub id: usize,
#[serde(alias = "description")]
pub desc: String,
pub done: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TodoList {
#[serde(default)]
pub goal: String,
#[serde(default)]
pub todos: Vec<TodoItem>,
}
impl TodoList {
pub fn new(goal: &str) -> Self {
Self {
goal: goal.to_string(),
todos: Vec::new(),
}
}
pub fn add(&mut self, task: &str) -> usize {
let id = self.todos.iter().map(|t| t.id).max().unwrap_or(0) + 1;
self.todos.push(TodoItem {
id,
desc: task.to_string(),
done: false,
});
id
}
pub fn mark_done(&mut self, id: usize) -> bool {
if let Some(item) = self.todos.iter_mut().find(|t| t.id == id) {
item.done = true;
true
} else {
false
}
}
pub fn has_incomplete(&self) -> bool {
self.todos.iter().any(|item| !item.done)
}
pub fn is_empty(&self) -> bool {
self.todos.is_empty()
}
pub fn render_for_model(&self) -> String {
let mut lines = Vec::new();
if !self.goal.is_empty() {
lines.push(format!("Goal: {}", self.goal));
}
lines.push(format!(
"Progress: {}/{} completed",
self.completed_count(),
self.todos.len()
));
for item in &self.todos {
let status = if item.done {
TodoStatus::Done
} else {
TodoStatus::Pending
};
lines.push(format!(" {} {}. {}", status.icon(), item.id, item.desc));
}
lines.join("\n")
}
pub fn incomplete_count(&self) -> usize {
self.todos.iter().filter(|item| !item.done).count()
}
pub fn completed_count(&self) -> usize {
self.todos.iter().filter(|item| item.done).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_and_add() {
let mut list = TodoList::new("Map Labs");
assert_eq!(list.add("Discover"), 1);
assert_eq!(list.add("Map columns"), 2);
assert_eq!(list.todos.len(), 2);
assert!(list.has_incomplete());
}
#[test]
fn test_mark_done() {
let mut list = TodoList::new("Test");
list.add("Task 1");
list.add("Task 2");
assert!(list.mark_done(1));
assert!(!list.mark_done(99));
assert_eq!(list.completed_count(), 1);
assert_eq!(list.incomplete_count(), 1);
}
#[test]
fn test_empty_list() {
let list = TodoList::default();
assert!(!list.has_incomplete());
assert!(list.is_empty());
}
#[test]
fn test_all_done() {
let mut list = TodoList::new("Test");
list.add("Done task");
list.mark_done(1);
assert!(!list.has_incomplete());
}
#[test]
fn test_render_for_model() {
let mut list = TodoList::new("Map Labs");
list.add("Discover");
list.add("Map");
list.mark_done(1);
let rendered = list.render_for_model();
assert!(rendered.contains("Goal: Map Labs"));
assert!(rendered.contains("Progress: 1/2 completed"));
assert!(rendered.contains("✓ 1. Discover"));
assert!(rendered.contains("○ 2. Map"));
}
#[test]
fn test_serialization_roundtrip() {
let mut list = TodoList::new("Roundtrip");
list.add("Step 1");
list.add("Step 2");
list.mark_done(1);
let json = serde_json::to_string(&list).unwrap();
let deserialized: TodoList = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.goal, "Roundtrip");
assert_eq!(deserialized.todos.len(), 2);
assert!(deserialized.todos[0].done);
assert!(!deserialized.todos[1].done);
}
}
+330 -55
View File
@@ -1,10 +1,15 @@
pub(crate) mod todo;
use crate::{
config::{Agent, Config, GlobalConfig},
utils::*,
};
use crate::config::ensure_parent_exists;
use crate::mcp::{MCP_INVOKE_META_FUNCTION_NAME_PREFIX, MCP_LIST_META_FUNCTION_NAME_PREFIX};
use crate::mcp::{
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
};
use crate::parsers::{bash, python};
use anyhow::{Context, Result, anyhow, bail};
use indexmap::IndexMap;
@@ -12,6 +17,7 @@ use indoc::formatdoc;
use rust_embed::Embed;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::fs::File;
use std::io::Write;
@@ -19,8 +25,10 @@ use std::{
collections::{HashMap, HashSet},
env, fs, io,
path::{Path, PathBuf},
process::{Command, Stdio},
};
use strum_macros::AsRefStr;
use todo::TODO_FUNCTION_PREFIX;
#[derive(Embed)]
#[folder = "assets/functions/"]
@@ -87,6 +95,19 @@ pub async fn eval_tool_calls(
}
let mut is_all_null = true;
for call in calls {
if let Some(checker) = &config.read().tool_call_tracker
&& let Some(msg) = checker.check_loop(&call.clone())
{
let dup_msg = format!("{{\"tool_call_loop_alert\":{}}}", &msg.trim());
println!(
"{}",
warning_text(format!("{}: ⚠️ Tool-call loop detected! ⚠️", &call.name).as_str())
);
let val = json!(dup_msg);
output.push(ToolResult::new(call, val));
is_all_null = false;
continue;
}
let mut result = call.eval(config).await?;
if result.is_null() {
result = json!("DONE");
@@ -244,22 +265,20 @@ impl Functions {
self.declarations.is_empty()
}
pub fn append_todo_functions(&mut self) {
self.declarations.extend(todo::todo_function_declarations());
}
pub fn clear_mcp_meta_functions(&mut self) {
self.declarations.retain(|d| {
!d.name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX)
&& !d.name.starts_with(MCP_LIST_META_FUNCTION_NAME_PREFIX)
&& !d.name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX)
&& !d.name.starts_with(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX)
});
}
pub fn append_mcp_meta_functions(&mut self, mcp_servers: Vec<String>) {
let mut invoke_function_properties = IndexMap::new();
invoke_function_properties.insert(
"server".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
..Default::default()
},
);
invoke_function_properties.insert(
"tool".to_string(),
JsonSchema {
@@ -275,32 +294,86 @@ impl Functions {
},
);
let mut search_function_properties = IndexMap::new();
search_function_properties.insert(
"query".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("Generalized explanation of what you want to do".into()),
..Default::default()
},
);
search_function_properties.insert(
"top_k".to_string(),
JsonSchema {
type_value: Some("integer".to_string()),
description: Some("How many results to return, between 1 and 20".into()),
default: Some(Value::from(8usize)),
..Default::default()
},
);
let mut describe_function_properties = IndexMap::new();
describe_function_properties.insert(
"tool".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("The name of the tool; e.g., search_issues".into()),
..Default::default()
},
);
for server in mcp_servers {
let search_function_name = format!("{}_{server}", MCP_SEARCH_META_FUNCTION_NAME_PREFIX);
let describe_function_name =
format!("{}_{server}", MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX);
let invoke_function_name = format!("{}_{server}", MCP_INVOKE_META_FUNCTION_NAME_PREFIX);
let invoke_function_declaration = FunctionDeclaration {
name: invoke_function_name.clone(),
description: formatdoc!(
r#"
Invoke the specified tool on the {server} MCP server. Always call {invoke_function_name} first to find the
correct names of tools before calling '{invoke_function_name}'.
Invoke the specified tool on the {server} MCP server. Always call {describe_function_name} first to
find the correct invocation schema for the given tool.
"#
),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(invoke_function_properties.clone()),
required: Some(vec!["server".to_string(), "tool".to_string()]),
required: Some(vec!["tool".to_string()]),
..Default::default()
},
agent: false,
};
let list_functions_declaration = FunctionDeclaration {
name: format!("{}_{}", MCP_LIST_META_FUNCTION_NAME_PREFIX, server),
description: format!("List all the available tools for the {server} MCP server"),
parameters: JsonSchema::default(),
let search_functions_declaration = FunctionDeclaration {
name: search_function_name.clone(),
description: formatdoc!(
r#"
Find candidate tools by keywords for the {server} MCP server. Returns small suggestions; fetch
schemas with {describe_function_name}.
"#
),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(search_function_properties.clone()),
required: Some(vec!["query".to_string()]),
..Default::default()
},
agent: false,
};
let describe_functions_declaration = FunctionDeclaration {
name: describe_function_name.clone(),
description: "Get the full JSON schema for exactly one MCP tool.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(describe_function_properties.clone()),
required: Some(vec!["tool".to_string()]),
..Default::default()
},
agent: false,
};
self.declarations.push(invoke_function_declaration);
self.declarations.push(list_functions_declaration);
self.declarations.push(search_functions_declaration);
self.declarations.push(describe_functions_declaration);
}
}
@@ -705,6 +778,10 @@ pub struct ToolCall {
pub name: String,
pub arguments: Value,
pub id: Option<String>,
/// Gemini 3's thought signature for stateful reasoning in function calling.
/// Must be preserved and sent back when submitting function responses.
#[serde(skip_serializing_if = "Option::is_none")]
pub thought_signature: Option<String>,
}
type CallConfig = (String, String, Vec<String>, HashMap<String, String>);
@@ -734,9 +811,15 @@ impl ToolCall {
name,
arguments,
id,
thought_signature: None,
}
}
pub fn with_thought_signature(mut self, thought_signature: Option<String>) -> Self {
self.thought_signature = thought_signature;
self
}
pub async fn eval(&self, config: &GlobalConfig) -> Result<Value> {
let (call_name, cmd_name, mut cmd_args, envs) = match &config.read().agent {
Some(agent) => self.extract_call_config_from_agent(config, agent)?,
@@ -771,51 +854,128 @@ impl ToolCall {
}
let output = match cmd_name.as_str() {
_ if cmd_name.starts_with(MCP_LIST_META_FUNCTION_NAME_PREFIX) => {
let registry_arc = {
let cfg = config.read();
cfg.mcp_registry
.clone()
.with_context(|| "MCP is not configured")?
};
registry_arc.catalog().await?
_ if cmd_name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX) => {
Self::search_mcp_tools(config, &cmd_name, &json_data).unwrap_or_else(|e| {
let error_msg = format!("MCP search failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
_ if cmd_name.starts_with(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX) => {
Self::describe_mcp_tool(config, &cmd_name, json_data)
.await
.unwrap_or_else(|e| {
let error_msg = format!("MCP describe failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
_ if cmd_name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX) => {
let server = json_data
.get("server")
.ok_or_else(|| anyhow!("Missing 'server' in arguments"))?
.as_str()
.ok_or_else(|| anyhow!("Invalid 'server' in arguments"))?;
let tool = json_data
.get("tool")
.ok_or_else(|| anyhow!("Missing 'tool' in arguments"))?
.as_str()
.ok_or_else(|| anyhow!("Invalid 'tool' in arguments"))?;
let arguments = json_data
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let registry_arc = {
let cfg = config.read();
cfg.mcp_registry
.clone()
.with_context(|| "MCP is not configured")?
};
let result = registry_arc.invoke(server, tool, arguments).await?;
serde_json::to_value(result)?
Self::invoke_mcp_tool(config, &cmd_name, &json_data)
.await
.unwrap_or_else(|e| {
let error_msg = format!("MCP tool invocation failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
_ => match run_llm_function(cmd_name, cmd_args, envs, agent_name)? {
Some(contents) => serde_json::from_str(&contents)
_ if cmd_name.starts_with(TODO_FUNCTION_PREFIX) => {
todo::handle_todo_tool(config, &cmd_name, &json_data).unwrap_or_else(|e| {
let error_msg = format!("Todo tool failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
_ => match run_llm_function(cmd_name, cmd_args, envs, agent_name) {
Ok(Some(contents)) => serde_json::from_str(&contents)
.ok()
.unwrap_or_else(|| json!({"output": contents})),
None => Value::Null,
Ok(None) => Value::Null,
Err(e) => serde_json::from_str(&e.to_string())
.ok()
.unwrap_or_else(|| json!({"output": e.to_string()})),
},
};
Ok(output)
}
async fn describe_mcp_tool(
config: &GlobalConfig,
cmd_name: &str,
json_data: Value,
) -> Result<Value> {
let server_id = cmd_name.replace(&format!("{MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX}_"), "");
let tool = json_data
.get("tool")
.ok_or_else(|| anyhow!("Missing 'tool' in arguments"))?
.as_str()
.ok_or_else(|| anyhow!("Invalid 'tool' in arguments"))?;
let registry_arc = {
let cfg = config.read();
cfg.mcp_registry
.clone()
.with_context(|| "MCP is not configured")?
};
let result = registry_arc.describe(&server_id, tool).await?;
Ok(serde_json::to_value(result)?)
}
fn search_mcp_tools(config: &GlobalConfig, cmd_name: &str, json_data: &Value) -> Result<Value> {
let server = cmd_name.replace(&format!("{MCP_SEARCH_META_FUNCTION_NAME_PREFIX}_"), "");
let query = json_data
.get("query")
.ok_or_else(|| anyhow!("Missing 'query' in arguments"))?
.as_str()
.ok_or_else(|| anyhow!("Invalid 'query' in arguments"))?;
let top_k = json_data
.get("top_k")
.cloned()
.unwrap_or_else(|| Value::from(8u64))
.as_u64()
.ok_or_else(|| anyhow!("Invalid 'top_k' in arguments"))? as usize;
let registry_arc = {
let cfg = config.read();
cfg.mcp_registry
.clone()
.with_context(|| "MCP is not configured")?
};
let catalog_items = registry_arc
.search_tools_server(&server, query, top_k)
.into_iter()
.map(|it| serde_json::to_value(&it).unwrap_or_default())
.collect();
Ok(Value::Array(catalog_items))
}
async fn invoke_mcp_tool(
config: &GlobalConfig,
cmd_name: &str,
json_data: &Value,
) -> Result<Value> {
let server = cmd_name.replace(&format!("{MCP_INVOKE_META_FUNCTION_NAME_PREFIX}_"), "");
let tool = json_data
.get("tool")
.ok_or_else(|| anyhow!("Missing 'tool' in arguments"))?
.as_str()
.ok_or_else(|| anyhow!("Invalid 'tool' in arguments"))?;
let arguments = json_data
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let registry_arc = {
let cfg = config.read();
cfg.mcp_registry
.clone()
.with_context(|| "MCP is not configured")?
};
let result = registry_arc.invoke(&server, tool, arguments).await?;
Ok(serde_json::to_value(result)?)
}
fn extract_call_config_from_agent(
&self,
config: &GlobalConfig,
@@ -866,7 +1026,9 @@ pub fn run_llm_function(
agent_name: Option<String>,
) -> Result<Option<String>> {
let mut bin_dirs: Vec<PathBuf> = vec![];
let mut command_name = cmd_name.clone();
if let Some(agent_name) = agent_name {
command_name = cmd_args[0].clone();
let dir = Config::agent_bin_dir(&agent_name);
if dir.exists() {
bin_dirs.push(dir);
@@ -888,17 +1050,36 @@ pub fn run_llm_function(
#[cfg(windows)]
let cmd_name = polyfill_cmd_name(&cmd_name, &bin_dirs);
let exit_code = run_command(&cmd_name, &cmd_args, Some(envs))
.map_err(|err| anyhow!("Unable to run {cmd_name}, {err}"))?;
let output = Command::new(&cmd_name)
.args(&cmd_args)
.envs(envs)
.stdout(Stdio::inherit())
.stderr(Stdio::piped())
.spawn()
.and_then(|child| child.wait_with_output())
.map_err(|err| anyhow!("Unable to run {command_name}, {err}"))?;
let exit_code = output.status.code().unwrap_or_default();
if exit_code != 0 {
bail!("Tool call exited with {exit_code}");
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
eprintln!("{stderr}");
}
let tool_error_message = format!("Tool call '{command_name}' exited with code {exit_code}");
eprintln!("{}", warning_text(&format!("⚠️ {tool_error_message} ⚠️")));
let mut error_json = json!({"tool_call_error": tool_error_message});
if !stderr.is_empty() {
error_json["stderr"] = json!(stderr);
}
debug!("Tool call error: {error_json:?}");
return Ok(Some(error_json.to_string()));
}
let mut output = None;
if temp_file.exists() {
let contents =
fs::read_to_string(temp_file).context("Failed to retrieve tool call output")?;
if !contents.is_empty() {
debug!("Tool {cmd_name} output: {}", contents);
debug!("Tool {command_name} output: {}", contents);
output = Some(contents);
}
};
@@ -920,3 +1101,97 @@ fn polyfill_cmd_name<T: AsRef<Path>>(cmd_name: &str, bin_dir: &[T]) -> String {
}
cmd_name
}
#[derive(Debug, Clone)]
pub struct ToolCallTracker {
last_calls: VecDeque<ToolCall>,
max_repeats: usize,
chain_len: usize,
}
impl ToolCallTracker {
pub fn new(max_repeats: usize, chain_len: usize) -> Self {
Self {
last_calls: VecDeque::new(),
max_repeats,
chain_len,
}
}
pub fn default() -> Self {
Self::new(2, 3)
}
pub fn check_loop(&self, new_call: &ToolCall) -> Option<String> {
if self.last_calls.len() < self.max_repeats {
return None;
}
if let Some(last) = self.last_calls.back()
&& self.calls_match(last, new_call)
{
let mut repeat_count = 1;
for i in (1..self.last_calls.len()).rev() {
if self.calls_match(&self.last_calls[i - 1], &self.last_calls[i]) {
repeat_count += 1;
if repeat_count >= self.max_repeats {
return Some(self.create_loop_message());
}
} else {
break;
}
}
}
let start = self.last_calls.len().saturating_sub(self.chain_len);
let chain: Vec<_> = self.last_calls.iter().skip(start).collect();
if chain.len() == self.chain_len {
let mut is_repeating = true;
for i in 0..chain.len() - 1 {
if !self.calls_match(chain[i], chain[i + 1]) {
is_repeating = false;
break;
}
}
if is_repeating && self.calls_match(chain[chain.len() - 1], new_call) {
return Some(self.create_loop_message());
}
}
None
}
fn calls_match(&self, a: &ToolCall, b: &ToolCall) -> bool {
a.name == b.name && a.arguments == b.arguments
}
fn create_loop_message(&self) -> String {
let message = r#"{"error":{"message":"⚠️ Tool-call loop detected! ⚠️","code":400,"param":"Use the output of the last call to this function and parameter-set then move on to the next step of workflow, change tools/parameters called, or request assistance in the conversation sream"}}"#;
if self.last_calls.len() >= self.chain_len {
let start = self.last_calls.len().saturating_sub(self.chain_len);
let chain: Vec<_> = self.last_calls.iter().skip(start).collect();
let mut loopset = "[".to_string();
for c in chain {
loopset +=
format!("{{\"name\":{},\"parameters\":{}}},", c.name, c.arguments).as_str();
}
let _ = loopset.pop();
loopset.push(']');
format!(
"{},\"call_history\":{}}}}}",
&message[..(&message.len() - 2)],
loopset
)
} else {
message.to_string()
}
}
pub fn record_call(&mut self, call: ToolCall) {
if self.last_calls.len() >= self.chain_len * self.max_repeats {
self.last_calls.pop_front();
}
self.last_calls.push_back(call);
}
}
+160
View File
@@ -0,0 +1,160 @@
use super::{FunctionDeclaration, JsonSchema};
use crate::config::GlobalConfig;
use anyhow::{Result, bail};
use indexmap::IndexMap;
use serde_json::{Value, json};
pub const TODO_FUNCTION_PREFIX: &str = "todo__";
pub fn todo_function_declarations() -> Vec<FunctionDeclaration> {
vec![
FunctionDeclaration {
name: format!("{TODO_FUNCTION_PREFIX}init"),
description: "Initialize a new todo list with a goal. Clears any existing todos."
.to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([(
"goal".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some(
"The overall goal to achieve when all todos are completed".into(),
),
..Default::default()
},
)])),
required: Some(vec!["goal".to_string()]),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{TODO_FUNCTION_PREFIX}add"),
description: "Add a new todo item to the list.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([(
"task".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("Description of the todo task".into()),
..Default::default()
},
)])),
required: Some(vec!["task".to_string()]),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{TODO_FUNCTION_PREFIX}done"),
description: "Mark a todo item as done by its id.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([(
"id".to_string(),
JsonSchema {
type_value: Some("integer".to_string()),
description: Some("The id of the todo item to mark as done".into()),
..Default::default()
},
)])),
required: Some(vec!["id".to_string()]),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{TODO_FUNCTION_PREFIX}list"),
description: "Display the current todo list with status of each item.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
..Default::default()
},
agent: false,
},
]
}
pub fn handle_todo_tool(config: &GlobalConfig, cmd_name: &str, args: &Value) -> Result<Value> {
let action = cmd_name
.strip_prefix(TODO_FUNCTION_PREFIX)
.unwrap_or(cmd_name);
match action {
"init" => {
let goal = args.get("goal").and_then(Value::as_str).unwrap_or_default();
let mut cfg = config.write();
let agent = cfg.agent.as_mut();
match agent {
Some(agent) => {
agent.init_todo_list(goal);
Ok(json!({"status": "ok", "message": "Initialized new todo list"}))
}
None => bail!("No active agent"),
}
}
"add" => {
let task = args.get("task").and_then(Value::as_str).unwrap_or_default();
if task.is_empty() {
return Ok(json!({"error": "task description is required"}));
}
let mut cfg = config.write();
let agent = cfg.agent.as_mut();
match agent {
Some(agent) => {
let id = agent.add_todo(task);
Ok(json!({"status": "ok", "id": id}))
}
None => bail!("No active agent"),
}
}
"done" => {
let id = args
.get("id")
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.map(|v| v as usize);
match id {
Some(id) => {
let mut cfg = config.write();
let agent = cfg.agent.as_mut();
match agent {
Some(agent) => {
if agent.mark_todo_done(id) {
Ok(
json!({"status": "ok", "message": format!("Marked todo {id} as done")}),
)
} else {
Ok(json!({"error": format!("Todo {id} not found")}))
}
}
None => bail!("No active agent"),
}
}
None => Ok(json!({"error": "id is required and must be a number"})),
}
}
"list" => {
let cfg = config.read();
let agent = cfg.agent.as_ref();
match agent {
Some(agent) => {
let list = agent.todo_list();
if list.is_empty() {
Ok(json!({"goal": "", "todos": []}))
} else {
Ok(serde_json::to_value(list)
.unwrap_or(json!({"error": "serialization failed"})))
}
}
None => bail!("No active agent"),
}
}
_ => bail!("Unknown todo action: {action}"),
}
}
+117 -27
View File
@@ -2,6 +2,7 @@ use crate::config::Config;
use crate::utils::{AbortSignal, abortable_run_with_spinner};
use crate::vault::interpolate_secrets;
use anyhow::{Context, Result, anyhow};
use bm25::{Document, Language, SearchEngine, SearchEngineBuilder};
use futures_util::future::BoxFuture;
use futures_util::{StreamExt, TryStreamExt, stream};
use indoc::formatdoc;
@@ -9,7 +10,7 @@ use rmcp::model::{CallToolRequestParam, CallToolResult};
use rmcp::service::RunningService;
use rmcp::transport::TokioChildProcess;
use rmcp::{RoleClient, ServiceExt};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
@@ -20,10 +21,46 @@ use std::sync::Arc;
use tokio::process::Command;
pub const MCP_INVOKE_META_FUNCTION_NAME_PREFIX: &str = "mcp_invoke";
pub const MCP_LIST_META_FUNCTION_NAME_PREFIX: &str = "mcp_list";
pub const MCP_SEARCH_META_FUNCTION_NAME_PREFIX: &str = "mcp_search";
pub const MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX: &str = "mcp_describe";
type ConnectedServer = RunningService<RoleClient, ()>;
#[derive(Clone, Debug, Default, Serialize)]
pub struct CatalogItem {
pub name: String,
pub server: String,
pub description: String,
}
#[derive(Debug)]
struct ServerCatalog {
engine: SearchEngine<String>,
items: HashMap<String, CatalogItem>,
}
impl ServerCatalog {
pub fn build_bm25(items: &HashMap<String, CatalogItem>) -> SearchEngine<String> {
let docs = items.values().map(|it| {
let contents = format!("{}\n{}\nserver:{}", it.name, it.description, it.server);
Document {
id: it.name.clone(),
contents,
}
});
SearchEngineBuilder::<String>::with_documents(Language::English, docs).build()
}
}
impl Clone for ServerCatalog {
fn clone(&self) -> Self {
Self {
engine: Self::build_bm25(&self.items),
items: self.items.clone(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
struct McpServersConfig {
#[serde(rename = "mcpServers")]
@@ -50,7 +87,8 @@ enum JsonField {
pub struct McpRegistry {
log_path: Option<PathBuf>,
config: Option<McpServersConfig>,
servers: HashMap<String, Arc<RunningService<RoleClient, ()>>>,
servers: HashMap<String, Arc<ConnectedServer>>,
catalogs: HashMap<String, ServerCatalog>,
}
impl McpRegistry {
@@ -173,7 +211,7 @@ impl McpRegistry {
.collect()
};
let results: Vec<(String, Arc<_>)> = stream::iter(
let results: Vec<(String, Arc<_>, ServerCatalog)> = stream::iter(
server_ids
.into_iter()
.map(|id| async { self.start_server(id).await }),
@@ -182,13 +220,24 @@ impl McpRegistry {
.try_collect()
.await?;
self.servers = results.into_iter().collect();
self.servers = results
.clone()
.into_iter()
.map(|(id, server, _)| (id, server))
.collect();
self.catalogs = results
.into_iter()
.map(|(id, _, catalog)| (id, catalog))
.collect();
}
Ok(())
}
async fn start_server(&self, id: String) -> Result<(String, Arc<ConnectedServer>)> {
async fn start_server(
&self,
id: String,
) -> Result<(String, Arc<ConnectedServer>, ServerCatalog)> {
let server = self
.config
.as_ref()
@@ -231,14 +280,33 @@ impl McpRegistry {
.await
.with_context(|| format!("Failed to start MCP server: {}", &server.command))?,
);
debug!(
"Available tools for MCP server {id}: {:?}",
service.list_tools(None).await?
);
let tools = service.list_tools(None).await?;
debug!("Available tools for MCP server {id}: {tools:?}");
let mut items_vec = Vec::new();
for t in tools.tools {
let name = t.name.to_string();
let description = t.description.unwrap_or_default().to_string();
items_vec.push(CatalogItem {
name,
server: id.clone(),
description,
});
}
let mut items_map = HashMap::new();
items_vec.into_iter().for_each(|it| {
items_map.insert(it.name.clone(), it);
});
let catalog = ServerCatalog {
engine: ServerCatalog::build_bm25(&items_map),
items: items_map,
};
info!("Started MCP server: {id}");
Ok((id.to_string(), service))
Ok((id.to_string(), service, catalog))
}
pub async fn stop_all_servers(mut self) -> Result<Self> {
@@ -268,26 +336,48 @@ impl McpRegistry {
}
}
pub fn catalog(&self) -> BoxFuture<'static, Result<Value>> {
let servers: Vec<(String, Arc<ConnectedServer>)> = self
pub fn search_tools_server(&self, server: &str, query: &str, top_k: usize) -> Vec<CatalogItem> {
let Some(catalog) = self.catalogs.get(server) else {
return vec![];
};
let engine = &catalog.engine;
let raw = engine.search(query, top_k.min(20));
raw.into_iter()
.filter_map(|r| catalog.items.get(&r.document.id))
.take(top_k)
.cloned()
.collect()
}
pub async fn describe(&self, server_id: &str, tool: &str) -> Result<Value> {
let server = self
.servers
.iter()
.map(|(id, s)| (id.clone(), s.clone()))
.collect();
.filter(|(id, _)| &server_id == id)
.map(|(_, s)| s.clone())
.next()
.ok_or(anyhow!("{server_id} MCP server not found in config"))?;
Box::pin(async move {
let mut out = Vec::with_capacity(servers.len());
for (id, server) in servers {
let tools = server.list_tools(None).await?;
let resources = server.list_resources(None).await.unwrap_or_default();
out.push(json!({
"server": id,
"tools": tools,
"resources": resources,
}));
let tool_schema = server
.list_tools(None)
.await?
.tools
.into_iter()
.find(|it| it.name == tool)
.ok_or(anyhow!(
"{tool} not found in {server_id} MCP server catalog"
))?
.input_schema;
Ok(json!({
"type": "object",
"properties": {
"tool": {
"type": "string",
},
"arguments": tool_schema
}
Ok(Value::Array(out))
})
}))
}
pub fn invoke(
+36 -4
View File
@@ -298,16 +298,48 @@ impl Rag {
top_k: usize,
rerank_model: Option<&str>,
abort_signal: AbortSignal,
) -> Result<(String, Vec<DocumentId>)> {
) -> Result<(String, String, Vec<DocumentId>)> {
let ret = abortable_run_with_spinner(
self.hybird_search(text, top_k, rerank_model),
"Searching",
abort_signal,
)
.await;
let (ids, documents): (Vec<_>, Vec<_>) = ret?.into_iter().unzip();
let embeddings = documents.join("\n\n");
Ok((embeddings, ids))
let results = ret?;
let ids: Vec<_> = results.iter().map(|(id, _)| *id).collect();
let embeddings = results
.iter()
.map(|(id, content)| {
let source = self.resolve_source(id);
format!("[Source: {source}]\n{content}")
})
.collect::<Vec<_>>()
.join("\n\n");
let sources = self.format_sources(&ids);
Ok((embeddings, sources, ids))
}
fn resolve_source(&self, id: &DocumentId) -> String {
let (file_index, _) = id.split();
self.data
.files
.get(&file_index)
.map(|f| f.path.clone())
.unwrap_or_else(|| "unknown".to_string())
}
fn format_sources(&self, ids: &[DocumentId]) -> String {
let mut seen = IndexSet::new();
for id in ids {
let (file_index, _) = id.split();
if let Some(file) = self.data.files.get(&file_index) {
seen.insert(file.path.clone());
}
}
seen.into_iter()
.map(|path| format!("- {path}"))
.collect::<Vec<_>>()
.join("\n")
}
pub async fn sync_documents(
+134 -3
View File
@@ -826,6 +826,14 @@ pub async fn run_repl_command(
_ => unknown_command()?,
},
None => {
if config
.read()
.agent
.as_ref()
.is_some_and(|a| a.continuation_count() > 0)
{
config.write().agent.as_mut().unwrap().reset_continuation();
}
let input = Input::from_str(config, line, None);
ask(config, abort_signal.clone(), input, true).await?;
}
@@ -874,9 +882,132 @@ async fn ask(
)
.await
} else {
Config::maybe_autoname_session(config.clone());
Config::maybe_compress_session(config.clone());
Ok(())
let should_continue = {
let cfg = config.read();
if let Some(agent) = &cfg.agent {
agent.auto_continue_enabled()
&& agent.continuation_count() < agent.max_auto_continues()
&& !agent.is_stale_response(&output)
&& agent.todo_list().has_incomplete()
} else {
false
}
};
if should_continue {
let full_prompt = {
let mut cfg = config.write();
let agent = cfg.agent.as_mut().expect("agent checked above");
agent.set_last_continuation_response(output.clone());
agent.increment_continuation();
let count = agent.continuation_count();
let max = agent.max_auto_continues();
let todo_state = agent.todo_list().render_for_model();
let remaining = agent.todo_list().incomplete_count();
let prompt = agent.continuation_prompt();
let color = if cfg.light_theme() {
nu_ansi_term::Color::LightGray
} else {
nu_ansi_term::Color::DarkGray
};
eprintln!(
"\n📋 {}",
color.italic().paint(format!(
"Auto-continuing ({count}/{max}): {remaining} incomplete todo(s) remain"
))
);
format!("{prompt}\n\n{todo_state}")
};
let continuation_input = Input::from_str(config, &full_prompt, None);
ask(config, abort_signal, continuation_input, false).await
} else {
if config
.read()
.agent
.as_ref()
.is_some_and(|a| a.continuation_count() > 0)
{
config.write().agent.as_mut().unwrap().reset_continuation();
}
Config::maybe_autoname_session(config.clone());
let needs_compression = {
let cfg = config.read();
let compression_threshold = cfg.compression_threshold;
cfg.session
.as_ref()
.is_some_and(|s| s.needs_compression(compression_threshold))
};
if needs_compression {
let agent_can_continue_after_compress = {
let cfg = config.read();
cfg.agent.as_ref().is_some_and(|agent| {
agent.auto_continue_enabled()
&& agent.continuation_count() < agent.max_auto_continues()
&& agent.todo_list().has_incomplete()
})
};
{
let mut cfg = config.write();
if let Some(session) = cfg.session.as_mut() {
session.set_compressing(true);
}
}
let color = if config.read().light_theme() {
nu_ansi_term::Color::LightGray
} else {
nu_ansi_term::Color::DarkGray
};
eprintln!("\n📢 {}", color.italic().paint("Compressing the session."),);
if let Err(err) = Config::compress_session(config).await {
log::warn!("Failed to compress the session: {err}");
}
if let Some(session) = config.write().session.as_mut() {
session.set_compressing(false);
}
if agent_can_continue_after_compress {
let full_prompt = {
let mut cfg = config.write();
let agent = cfg.agent.as_mut().expect("agent checked above");
agent.increment_continuation();
let count = agent.continuation_count();
let max = agent.max_auto_continues();
let todo_state = agent.todo_list().render_for_model();
let remaining = agent.todo_list().incomplete_count();
let prompt = agent.continuation_prompt();
let color = if cfg.light_theme() {
nu_ansi_term::Color::LightGray
} else {
nu_ansi_term::Color::DarkGray
};
eprintln!(
"\n📋 {}",
color.italic().paint(format!(
"Auto-continuing after compression ({count}/{max}): {remaining} incomplete todo(s) remain"
))
);
format!("{prompt}\n\n{todo_state}")
};
let continuation_input = Input::from_str(config, &full_prompt, None);
return ask(config, abort_signal, continuation_input, false).await;
}
} else {
Config::maybe_compress_session(config.clone());
}
Ok(())
}
}
}