Compare commits
95 Commits
e69352ee2d
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b1cd8351fa | |||
| ccf5e73341 | |||
| be5d280c32 | |||
| 6633a8c0bf | |||
| 097d8936e3 | |||
| 8a53b7934b | |||
| 0facb15e32 | |||
| c172736362 | |||
| 4a2b9fa42a | |||
| 98db37866c | |||
| ad31fbd169 | |||
| d69e28fd39 | |||
| 279eaa5300 | |||
| e687d78931 | |||
| 0c2e4df647 | |||
| 6221875f64 | |||
| 895b9c27db | |||
| e661ca2eda | |||
| 7066edd904 | |||
| 61bdf29bea | |||
| ef39c7d9ff | |||
| e9e46158e7 | |||
| 34dc4b0dce | |||
| cd226577e7 | |||
| b5fc633454 | |||
| 484b18ef16 | |||
| 7333046cfe | |||
| 815f0e5c39 | |||
| dacccbfcf7 | |||
| 5370637274 | |||
| e6da252a5a | |||
| 4aaff21f45 | |||
| 2678afe02b | |||
| 558b764db8 | |||
| 0bb312a85c | |||
| d81d233527 | |||
| 597f823bdf | |||
| 81c037515e | |||
| 3c7d19da07 | |||
| 4536d00067 | |||
| 98d16d9a56 | |||
| 26de81e84e | |||
| 20c28b55d5 | |||
| 7d6f1dda26 | |||
| 9a061944ae | |||
| 1f50af0974 | |||
| bdacf9fc78 | |||
| a9f2a5edc2 | |||
| 2df8b1a541 | |||
| de055bf8a4 | |||
| 8fb0eece4b | |||
| ba03c3037d | |||
| afa0e4af67 | |||
| 5a9a00bc6f | |||
| e7bb668ac7 | |||
| 04498b96ec | |||
| eb2843d38a | |||
| 696ce03ee4 | |||
| a3d67bfbf7 | |||
| 5bd0766a60 | |||
| 35e1b14843 | |||
| 503c9b4699 | |||
| 7a8b09542d | |||
| da5cd21c1c | |||
| 27fcb1fc15 | |||
| e292c414c5 | |||
| 8a2f18204f | |||
| c70ac98223 | |||
| 249d1fc881 | |||
| 3f4fd91b3f | |||
| 48c52b5829 | |||
| f58f751c59 | |||
| fc7fdc98b4 | |||
| f4d7d0fb73 | |||
| 4b38f53488 | |||
| 186422ff58 | |||
| 9bc4f8b621 | |||
| 84497d3d65 | |||
| 3ea9116a23 | |||
| bfcd73c32a | |||
| 3cd3ba55ff | |||
| 3535edba79 | |||
| bf0343e245 | |||
| b001ae4c18 | |||
| 9ce088a530 | |||
| 16f3f71188 | |||
| 0af5fa02f9 | |||
| d6a0676264 | |||
| b582bab17c | |||
| a8732c63d6 | |||
| 389d0b768f | |||
| 70a251a7e2 | |||
| 462f136596 | |||
| bf9d7d750e | |||
| 540ec648c9 |
@@ -21,25 +21,25 @@ body:
|
||||
value: |
|
||||
I tried this:
|
||||
|
||||
1. `loki`
|
||||
1. `coyote`
|
||||
|
||||
I expected this to happen:
|
||||
|
||||
Instead, this happened:
|
||||
- type: textarea
|
||||
id: loki-log
|
||||
id: coyote-log
|
||||
attributes:
|
||||
label: Loki log
|
||||
description: Include the Loki log file to help diagnose the issue. (`loki --info` to see the log_path)
|
||||
label: Coyote log
|
||||
description: Include the Coyote log file to help diagnose the issue. (`coyote --info` to see the log_path)
|
||||
value: |
|
||||
| OS | Log file location |
|
||||
| ------- | ----------------------------------------------------- |
|
||||
| Linux | `~/.cache/loki/loki.log` |
|
||||
| Mac | `~/Library/Logs/loki/loki.log` |
|
||||
| Windows | `C:\Users\<User>\AppData\Local\loki\loki.log` |
|
||||
| Linux | `~/.cache/coyote/coyote.log` |
|
||||
| Mac | `~/Library/Logs/coyote/coyote.log` |
|
||||
| Windows | `C:\Users\<User>\AppData\Local\coyote\coyote.log` |
|
||||
|
||||
```
|
||||
please provide a copy of your loki log file here if possible; you may need to redact some of the lines
|
||||
please provide a copy of your coyote log file here if possible; you may need to redact some of the lines
|
||||
```
|
||||
|
||||
- type: input
|
||||
@@ -57,13 +57,13 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: loki-version
|
||||
id: coyote-version
|
||||
attributes:
|
||||
label: Loki Version
|
||||
label: Coyote Version
|
||||
description: >
|
||||
Loki version (`loki --version` if using a release, `git describe` if building
|
||||
Coyote version (`coyote --version` if using a release, `git describe` if building
|
||||
from main).
|
||||
**Make sure that you are using the [latest loki release](https://github.com/Dark-Alex-17/loki/releases) or a newer main build**
|
||||
placeholder: "loki 0.1.0"
|
||||
**Make sure that you are using the [latest coyote release](https://github.com/Dark-Alex-17/coyote/releases) or a newer main build**
|
||||
placeholder: "coyote 0.1.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -98,9 +98,9 @@ jobs:
|
||||
# Ignore Act's local artifact dir noise
|
||||
echo artifacts/ >> .git/info/exclude || true
|
||||
|
||||
# Edit the version line right after name="loki"
|
||||
# Edit the version line right after name="coyote"
|
||||
sed -E -i '
|
||||
/^[[:space:]]*name[[:space:]]*=[[:space:]]*"loki"[[:space:]]*$/ {
|
||||
/^[[:space:]]*name[[:space:]]*=[[:space:]]*"coyote"[[:space:]]*$/ {
|
||||
n
|
||||
s|^[[:space:]]*version[[:space:]]*=[[:space:]]*"[^"]*"|version = "'"$VERSION"'"|
|
||||
}
|
||||
@@ -278,7 +278,7 @@ jobs:
|
||||
- name: Verify file
|
||||
shell: bash
|
||||
run: |
|
||||
file target/${{ matrix.target }}/release/loki
|
||||
file target/${{ matrix.target }}/release/coyote
|
||||
|
||||
- name: Test
|
||||
if: matrix.target != 'aarch64-apple-darwin' && matrix.target != 'aarch64-pc-windows-msvc'
|
||||
@@ -382,11 +382,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
# Set environment variables
|
||||
macos_sha="$(cat ./artifacts/loki-x86_64-apple-darwin.sha256 | awk '{print $1}')"
|
||||
macos_sha="$(cat ./artifacts/coyote-x86_64-apple-darwin.sha256 | awk '{print $1}')"
|
||||
echo "MACOS_SHA=$macos_sha" >> $GITHUB_ENV
|
||||
macos_sha_arm="$(cat ./artifacts/loki-aarch64-apple-darwin.sha256 | awk '{print $1}')"
|
||||
macos_sha_arm="$(cat ./artifacts/coyote-aarch64-apple-darwin.sha256 | awk '{print $1}')"
|
||||
echo "MACOS_SHA_ARM=$macos_sha_arm" >> $GITHUB_ENV
|
||||
linux_sha="$(cat ./artifacts/loki-x86_64-unknown-linux-musl.sha256 | awk '{print $1}')"
|
||||
linux_sha="$(cat ./artifacts/coyote-x86_64-unknown-linux-musl.sha256 | awk '{print $1}')"
|
||||
echo "LINUX_SHA=$linux_sha" >> $GITHUB_ENV
|
||||
release_version="$(cat ./artifacts/release-version)"
|
||||
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
|
||||
@@ -402,23 +402,23 @@ jobs:
|
||||
if: env.ACT != 'true'
|
||||
run: |
|
||||
# run packaging script
|
||||
python "./deployment/homebrew/packager.py" ${{ env.RELEASE_VERSION }} "./deployment/homebrew/loki.rb.template" "./loki.rb" ${{ env.MACOS_SHA }} ${{ env.MACOS_SHA_ARM }} ${{ env.LINUX_SHA }}
|
||||
python "./deployment/homebrew/packager.py" ${{ env.RELEASE_VERSION }} "./deployment/homebrew/coyote.rb.template" "./coyote.rb" ${{ env.MACOS_SHA }} ${{ env.MACOS_SHA_ARM }} ${{ env.LINUX_SHA }}
|
||||
|
||||
- name: Push changes to Homebrew tap
|
||||
if: env.ACT != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.LOKI_GITHUB_TOKEN }}
|
||||
TOKEN: ${{ secrets.COYOTE_GITHUB_TOKEN }}
|
||||
run: |
|
||||
# push to Git
|
||||
git config --global user.name "Dark-Alex-17"
|
||||
git config --global user.email "alex.j.tusa@gmail.com"
|
||||
git clone https://Dark-Alex-17:${{ secrets.LOKI_GITHUB_TOKEN }}@github.com/Dark-Alex-17/homebrew-loki.git
|
||||
rm homebrew-loki/Formula/loki.rb
|
||||
cp loki.rb homebrew-loki/Formula
|
||||
cd homebrew-loki
|
||||
git clone https://Dark-Alex-17:${{ secrets.COYOTE_GITHUB_TOKEN }}@github.com/Dark-Alex-17/homebrew-coyote.git
|
||||
rm homebrew-coyote/Formula/coyote.rb
|
||||
cp coyote.rb homebrew-coyote/Formula
|
||||
cd homebrew-coyote
|
||||
git add .
|
||||
git diff-index --quiet HEAD || git commit -am "Update formula for Loki release ${{ env.RELEASE_VERSION }}"
|
||||
git push https://$TOKEN@github.com/Dark-Alex-17/homebrew-loki.git
|
||||
git diff-index --quiet HEAD || git commit -am "Update formula for Coyote release ${{ env.RELEASE_VERSION }}"
|
||||
git push https://$TOKEN@github.com/Dark-Alex-17/homebrew-coyote.git
|
||||
|
||||
publish-crate:
|
||||
needs: publish-github-release
|
||||
|
||||
+1
-1
@@ -3,5 +3,5 @@
|
||||
/.env
|
||||
!cli/**
|
||||
.idea/
|
||||
/loki.iml
|
||||
/coyote.iml
|
||||
/.idea/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"type":"rust","build":"cargo build","test":"cargo test","check":"cargo check","_detected_by":"heuristic","_cached_at":"2026-04-13T13:36:33-06:00"}
|
||||
+115
-4
@@ -1,3 +1,114 @@
|
||||
## v0.5.0 (2026-05-27)
|
||||
|
||||
### Feat
|
||||
|
||||
- rename Loki to Coyote
|
||||
|
||||
### Fix
|
||||
|
||||
- bash-based user interactions in agents accidentally regressed in graph implementation
|
||||
- Claude function calling in agent contexts
|
||||
- Claude code rate limit error per new Claude changes
|
||||
|
||||
## v0.4.0 (2026-05-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- LLM node failures propgate up
|
||||
- Added .install remote tab completions to the REPL
|
||||
- feature complete install remote with category selection
|
||||
- Support to interactively add secrets to Coyote that are missing from MCP configs when merging
|
||||
- Added MCP config merging support for remote asset installations
|
||||
- install remote now writes files to disk
|
||||
- Created basic install_remote functions
|
||||
- Created a more comprehensive and immediately useful default config for first runs
|
||||
- Created an example graph-based agent called deep-research
|
||||
- Improved coder agent that is now a graph-based agent
|
||||
- Removed indicatif spinners. The UX just won't stop clobbering for parallel graph nodes
|
||||
- Added agent variables support for graph agents and improved script executor to use the same environment variables as normal agent tool calling for further flexibility
|
||||
- Improved UX with colored spinners for parallel graph agents and no clobbering outputs for sub-agents
|
||||
- created new graph-based deep-research agent
|
||||
- improved UX for parallel graph execution
|
||||
- added branch progress tracker for better visualization of parallel graph super-steps
|
||||
- Removed the jira-helper agent and replaced it with the atlassian role
|
||||
- created the RenderMode enum to suppress stdout streaming during parallel graph super-steps
|
||||
- Full support for map node types
|
||||
- implemented the frontier-based scheduling for the graph executor with simplified state management (gotta love .clone)
|
||||
- validation support for parallel graph execution; restricted map nodes to only run for nodes without next targets and not supporting chained map nodes
|
||||
- created the staging area for state merges per super-step and created the built-in reducers (and their application) for the state merge phase of a super step
|
||||
- scaffolding work for fan-out nodes for parallel branch execution support and stubbed out Map node types
|
||||
- Coyote can now update itself via .update and --update commands
|
||||
- added a .edit command for editing the MCP configuration file
|
||||
- Created a new .install command to install bundled assets on-demand
|
||||
- migrated llm node validation to graph loading time instead of graph runtime
|
||||
- ripped out user input timeout scaffolding for approval and input node types; implementation can't be done cleanly
|
||||
- added additional support for all RAG-configuration fields in RAG nodes
|
||||
- initial support for RAG nodes in the graph execution system
|
||||
- implemented structured logging for graph execution
|
||||
- merged normal agent config and graph agent configs into one file (either/or)
|
||||
- added structured-output extraction for llm and agent nodes
|
||||
- created full llm node runtime implementation
|
||||
- scaffolded together the initial llm node type and its executor
|
||||
- wired together graph execution and agent graph dispatch
|
||||
- implemented support for the graph executor
|
||||
- created the approval node executor and the input node executor for user interaction
|
||||
- Added initial support for native Coyote agent nodes in the graph-based agent system
|
||||
- Added direct script invocation support for graph-based agents
|
||||
- Added graph validation
|
||||
- Implemented state management for agent graphs
|
||||
- initial agent graph scaffolding
|
||||
- add auto-continue support to all contexts
|
||||
- dynamic tab completions now show the sessions for a given agent instead of only listing global sessions
|
||||
- legacy SSE support for MCP server configurations
|
||||
- support http/sse transport types for MCP server configurations so it fully supports claude desktop-style MCP configs
|
||||
- 99% complete migration to new state structs to get away from God-Config struct; i.e. AppConfig, AppState, and RequestContext
|
||||
- Automatic runtime customization using shebangs
|
||||
- Created a demo TypeScript tool and a get_current_weather function in TypeScript
|
||||
- Updated the Python demo tool to show all possible parameter types and variations
|
||||
- Added TypeScript tool support using the refactored common ScriptedLanguage trait
|
||||
|
||||
### Fix
|
||||
|
||||
- Generified the functions usage of script detection for an executable bit on unix systems
|
||||
- merge required claude code system prompt into instructions
|
||||
- updated argc argument passing in run-tool and run-agent scripts
|
||||
- Added additional graph validation for parallel reads and writes with dependencies between nodes states
|
||||
- bug in next_single method and improved outcome handling for LLM node execution
|
||||
- inline RAG bug when globbing files by extension without subdirectory globbing
|
||||
- update the estimate_token_length function to use the standard word count method
|
||||
- removed unnecessary regenerate logic for sessions and use the same logic for all contexts; prevents a panic on empty message list
|
||||
- error when users try to start a session on a graph agent
|
||||
- added on_other field for approval nodes so users can specify an alternative free-text target when none of the options match what they want
|
||||
- accidentally added back in full agent tools on LLM nodes
|
||||
- Improve the coder agent's usage of tools
|
||||
- make the agent__collect escalation-aware so it doesn't freeze on sub-agent escalations
|
||||
- check for an existing session before starting up MCP servers when switching to a role
|
||||
- do not switch to agent if a session is active.
|
||||
- Do not append todo instructions when function calling is disabled
|
||||
- a bug in the dynamic completions because the crate name is coyote-ai but the binary is named coyote
|
||||
- bug found by copilot that would create a lock on the PollSender for sse-based MCP servers
|
||||
- Accidental shadow of temp_file function for Windows function calling
|
||||
- upgraded to newer rmcp version to get native-tls support
|
||||
- RagCache was not being used for agent and sub-agent instantiation
|
||||
- TypeScript function args were being passed as objects rather than direct parameters
|
||||
- Added in forgotten wrapper scripts for TypeScript tools
|
||||
- don't shadow variables in binary path handling for Windows
|
||||
- Tool call improvements for Windows systems
|
||||
|
||||
### Refactor
|
||||
|
||||
- migrated llm nodes to use Roles to simplify instructions handling and to function like inline roles
|
||||
- migrated the next_node and apply_state_updates logic for LLM nodes into the LlmExecutor
|
||||
- fully complete state re-architecting
|
||||
- Fully ripped out the god Config struct
|
||||
- Deprecated old Config struct initialization logic
|
||||
- migrate functions and MCP servers to AppConfig
|
||||
- Migrate the vault/bare_init logic
|
||||
- created a single install_builtins free function to remove from Config::init
|
||||
- partial migration to init in AppConfig
|
||||
- Extracted common Python parser logic into a common.rs module
|
||||
- python tools now use tree-sitter queries instead of AST
|
||||
|
||||
## v0.3.0 (2026-04-02)
|
||||
|
||||
### Feat
|
||||
@@ -21,7 +132,7 @@
|
||||
- Created a CodeRabbit-style code-reviewer agent
|
||||
- Added configuration option in agents to indicate the timeout for user input before proceeding (defaults to 5 minutes)
|
||||
- Added support for sub-agents to escalate user interaction requests from any depth to the parent agents for user interactions
|
||||
- built-in user interaction tools to remove the need for the list/confirm/etc prompts in prompt tools and to enhance user interactions in Loki
|
||||
- built-in user interaction tools to remove the need for the list/confirm/etc prompts in prompt tools and to enhance user interactions in Coyote
|
||||
- Experimental update to sisyphus to use the new parallel agent spawning system
|
||||
- Added an agent configuration property that allows auto-injecting sub-agent spawning instructions (when using the built-in sub-agent spawning system)
|
||||
- Auto-dispatch support of sub-agents and support for the teammate pattern between subagents
|
||||
@@ -75,7 +186,7 @@
|
||||
|
||||
- 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 Sisyphus agent to make Coyote 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
|
||||
@@ -135,8 +246,8 @@
|
||||
- Support for secret injection into the global config file (API keys, for example)
|
||||
- Improved MCP handling toggle handling
|
||||
- Secret injection into the MCP configuration
|
||||
- added REPL support for interacting with the Loki vault
|
||||
- Integrated gman with Loki to create a vault and added flags to configure the Loki vault
|
||||
- added REPL support for interacting with the Coyote vault
|
||||
- Integrated gman with Coyote to create a vault and added flags to configure the Coyote vault
|
||||
- Added a default session to the jira helper to make interaction more natural
|
||||
- Created the repo-analyzer role
|
||||
- Created the coder and sql agents
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
Contributors are very welcome! **No contribution is too small and all contributions are valued.**
|
||||
|
||||
## Rust
|
||||
You'll need to have the stable Rust toolchain installed in order to develop Loki.
|
||||
You'll need to have the stable Rust toolchain installed in order to develop Coyote.
|
||||
|
||||
The Rust toolchain (stable) can be installed via rustup using the following command:
|
||||
|
||||
@@ -84,5 +84,5 @@ Claude, etc.) is not permitted unless explicitly disclosed and approved.
|
||||
Submissions must certify that the contributor understands and can maintain the code they submit.
|
||||
|
||||
## Questions? Reach out to me!
|
||||
If you encounter any questions while developing Loki, please don't hesitate to reach out to me at
|
||||
If you encounter any questions while developing Coyote, please don't hesitate to reach out to me at
|
||||
alex.j.tusa@gmail.com. I'm happy to help contributors in any way I can, regardless of if they're new or experienced!
|
||||
|
||||
+6
-6
@@ -1,19 +1,19 @@
|
||||
# Credits
|
||||
|
||||
## AIChat
|
||||
Loki originally started as a fork of the fantastic
|
||||
Coyote 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
|
||||
servers to be specified per agent. Since then, Coyote 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
|
||||
Today, Coyote 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
|
||||
Coyote is now developed and maintained as an independent project. Full credit
|
||||
for the original foundation goes to the developers of the wonderful
|
||||
AIChat project.
|
||||
|
||||
@@ -21,10 +21,10 @@ 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),
|
||||
Coyote 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
|
||||
While Coyote has since diverged significantly and is now developed as an
|
||||
independent project, its early foundation and inspiration came from the
|
||||
AIChat project.
|
||||
|
||||
|
||||
Generated
+901
-482
File diff suppressed because it is too large
Load Diff
+20
-22
@@ -1,16 +1,16 @@
|
||||
[package]
|
||||
name = "loki-ai"
|
||||
version = "0.3.0"
|
||||
name = "coyote-ai"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
||||
description = "An all-in-one, batteries included LLM CLI Tool"
|
||||
keywords = ["chatgpt", "llm", "cli", "ai", "repl"]
|
||||
homepage = "https://github.com/Dark-Alex-17/loki"
|
||||
repository = "https://github.com/Dark-Alex-17/loki"
|
||||
homepage = "https://github.com/Dark-Alex-17/coyote"
|
||||
repository = "https://github.com/Dark-Alex-17/coyote"
|
||||
categories = ["command-line-utilities"]
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
rust-version = "1.89.0"
|
||||
rust-version = "1.95.0"
|
||||
exclude = [".github", "CONTRIBUTING.md"]
|
||||
|
||||
[dependencies]
|
||||
@@ -22,7 +22,7 @@ dunce = "1.0.5"
|
||||
futures-util = "0.3.29"
|
||||
inquire = "0.9.4"
|
||||
is-terminal = "0.4.9"
|
||||
reedline = "0.46.0"
|
||||
reedline = "0.47.0"
|
||||
serde = { version = "1.0.152", features = ["derive"] }
|
||||
serde_json = { version = "1.0.93", features = ["preserve_order"] }
|
||||
serde_yaml = "0.9.17"
|
||||
@@ -34,10 +34,6 @@ tokio = { version = "1.34.0", features = [
|
||||
"rt-multi-thread",
|
||||
"full",
|
||||
] }
|
||||
tokio-graceful = "0.2.2"
|
||||
tokio-stream = { version = "0.1.15", default-features = false, features = [
|
||||
"sync",
|
||||
] }
|
||||
crossterm = "0.29.0"
|
||||
chrono = "0.4.23"
|
||||
bincode = { version = "2.0.0", features = [
|
||||
@@ -51,7 +47,7 @@ nu-ansi-term = "0.50.0"
|
||||
async-trait = "0.1.74"
|
||||
textwrap = "0.16.0"
|
||||
ansi_colours = "1.2.2"
|
||||
reqwest-eventsource = "0.6.0"
|
||||
eventsource-stream = "0.2.3"
|
||||
log = "0.4.28"
|
||||
log4rs = { version = "1.4.0", features = ["file_appender"] }
|
||||
shell-words = "1.1.0"
|
||||
@@ -59,20 +55,14 @@ sha2 = "0.10.8"
|
||||
unicode-width = "0.2.0"
|
||||
async-recursion = "1.1.1"
|
||||
http = "1.1.0"
|
||||
http-body-util = "0.1"
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["server-auto", "client-legacy"] }
|
||||
time = { version = "0.3.36", features = ["macros"] }
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
hmac = "0.12.1"
|
||||
aws-smithy-eventstream = "0.60.4"
|
||||
urlencoding = "2.1.3"
|
||||
unicode-segmentation = "1.11.0"
|
||||
json-patch = { version = "4.0.0", default-features = false }
|
||||
bitflags = "2.5.0"
|
||||
path-absolutize = "3.1.1"
|
||||
hnsw_rs = "0.3.0"
|
||||
rayon = "1.10.0"
|
||||
uuid = { version = "1.9.1", features = ["v4"] }
|
||||
scraper = { version = "0.23.1", default-features = false, features = [
|
||||
"deterministic",
|
||||
@@ -97,7 +87,6 @@ rmcp = { version = "1.5.0", features = [
|
||||
] }
|
||||
num_cpus = "1.17.0"
|
||||
tree-sitter = "0.26.8"
|
||||
tree-sitter-language = "0.1"
|
||||
tree-sitter-python = "0.25.0"
|
||||
tree-sitter-typescript = "0.23"
|
||||
colored = "3.0.0"
|
||||
@@ -107,15 +96,24 @@ clap_complete_nushell = "4.5.9"
|
||||
open = "5"
|
||||
rand = { version = "0.10.0", features = ["default"] }
|
||||
url = "2.5.8"
|
||||
self_update = { version = "0.44", default-features = false, features = [
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"archive-tar",
|
||||
"compression-flate2",
|
||||
"archive-zip",
|
||||
"compression-zip-deflate",
|
||||
] }
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.12.0"
|
||||
version = "0.13.3"
|
||||
features = [
|
||||
"json",
|
||||
"multipart",
|
||||
"stream",
|
||||
"form",
|
||||
"socks",
|
||||
"rustls-tls",
|
||||
"rustls-tls-native-roots",
|
||||
"rustls",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
@@ -140,7 +138,7 @@ pretty_assertions = "1.4.0"
|
||||
serial_test = "3"
|
||||
|
||||
[[bin]]
|
||||
name = "loki"
|
||||
name = "coyote"
|
||||
path = "src/main.rs"
|
||||
|
||||
[profile.release]
|
||||
|
||||
@@ -1,120 +1,113 @@
|
||||
# Loki: All-in-one, batteries-included LLM CLI Tool
|
||||
# Coyote: All-in-one, batteries-included LLM CLI Tool
|
||||
|
||||

|
||||
[](https://crates.io/crates/loki-ai)
|
||||

|
||||

|
||||
[](https://github.com/Dark-Alex-17/loki/releases)
|
||||

|
||||
[](https://crates.io/crates/coyote-ai)
|
||||

|
||||

|
||||
[](https://github.com/Dark-Alex-17/coyote/releases)
|
||||
|
||||
Loki is an all-in-one, batteries-included, LLM CLI tool featuring Shell Assistant, CLI & REPL Mode, RAG, AI Tools &
|
||||
Coyote is an all-in-one, batteries-included, LLM CLI tool featuring Shell Assistant, CLI & REPL Mode, RAG, AI Tools &
|
||||
Agents, and More.
|
||||
|
||||
It is designed to include a number of useful agents, roles, macros, and more so users can get up and running with Loki
|
||||
in as little time as possible.
|
||||
It is designed to include a number of useful agents, roles, macros, and more so users can get up and running with Coyote
|
||||
in as little time as possible. You can also install entire bundles of agents, roles, macros, tools, and MCP servers from
|
||||
any git repository. See [Sharing Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Sharing-Configurations) for more information.
|
||||
|
||||

|
||||

|
||||
|
||||
Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration guide](https://github.com/Dark-Alex-17/loki/wiki/AIChat-Migration) to get started.
|
||||
Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration guide](https://github.com/Dark-Alex-17/coyote/wiki/AIChat-Migration) to get started.
|
||||
|
||||
## Quick Links
|
||||
* [AIChat Migration Guide](https://github.com/Dark-Alex-17/loki/wiki/AIChat-Migration): Coming from AIChat? Follow the migration guide to get started.
|
||||
* [Installation](#install): Install Loki
|
||||
* [Getting Started](#getting-started): Get started with Loki by doing first-run setup steps.
|
||||
* [REPL](https://github.com/Dark-Alex-17/loki/wiki/REPL): Interactive Read-Eval-Print Loop for conversational interactions with LLMs and Loki.
|
||||
* [Custom REPL Prompt](https://github.com/Dark-Alex-17/loki/wiki/REPL-Prompt): Customize the REPL prompt to provide useful contextual information.
|
||||
* [Vault](https://github.com/Dark-Alex-17/loki/wiki/Vault): Securely store and manage sensitive information such as API keys and credentials.
|
||||
* [Shell Integrations](https://github.com/Dark-Alex-17/loki/wiki/Shell-Integrations): Seamlessly integrate Loki with your shell environment for enhanced command-line assistance.
|
||||
* [Function Calling](https://github.com/Dark-Alex-17/loki/wiki/Tools): Leverage function calling capabilities to extend Loki's functionality with custom tools
|
||||
* [Creating Custom Tools](https://github.com/Dark-Alex-17/loki/wiki/Custom-Tools): You can create your own custom tools to enhance Loki's capabilities.
|
||||
* [Create Custom Python Tools](https://github.com/Dark-Alex-17/loki/wiki/Custom-Tools#custom-python-based-tools)
|
||||
* [Create Custom TypeScript Tools](https://github.com/Dark-Alex-17/loki/wiki/Custom-Tools#custom-typescript-based-tools)
|
||||
* [Create Custom Bash Tools](https://github.com/Dark-Alex-17/loki/wiki/Custom-Bash-Tools)
|
||||
* [Bash Prompt Utilities](https://github.com/Dark-Alex-17/loki/wiki/Bash-Prompt-Helpers)
|
||||
* [First-Class MCP Server Support](https://github.com/Dark-Alex-17/loki/wiki/MCP-Servers): Easily connect and interact with MCP servers for advanced functionality.
|
||||
* [Macros](https://github.com/Dark-Alex-17/loki/wiki/Macros): Automate repetitive tasks and workflows with Loki "scripts" (macros).
|
||||
* [RAG](https://github.com/Dark-Alex-17/loki/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
|
||||
* [Sessions](https://github.com/Dark-Alex-17/loki/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
|
||||
* [Roles](https://github.com/Dark-Alex-17/loki/wiki/Roles): Customize model behavior for specific tasks or domains.
|
||||
* [Agents](https://github.com/Dark-Alex-17/loki/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
|
||||
* [Todo System](https://github.com/Dark-Alex-17/loki/wiki/TODO-System): Built-in task tracking for improved agent reliability with smaller models.
|
||||
* [Environment Variables](https://github.com/Dark-Alex-17/loki/wiki/Environment-Variables): Override and customize your Loki configuration at runtime with environment variables.
|
||||
* [Client Configurations](https://github.com/Dark-Alex-17/loki/wiki/Clients): Configuration instructions for various LLM providers.
|
||||
* [Authentication (API Key & OAuth)](https://github.com/Dark-Alex-17/loki/wiki/Clients#authentication): Authenticate with API keys or OAuth for subscription-based access.
|
||||
* [Patching API Requests](https://github.com/Dark-Alex-17/loki/wiki/Patches): Learn how to patch API requests for advanced customization.
|
||||
* [Custom Themes](https://github.com/Dark-Alex-17/loki/wiki/Themes): Change the look and feel of Loki to your preferences with custom themes.
|
||||
* [History](#history): A history of how Loki came to be.
|
||||
* [AIChat Migration Guide](https://github.com/Dark-Alex-17/coyote/wiki/AIChat-Migration): Coming from AIChat? Follow the migration guide to get started.
|
||||
* [Installation](#install): Install Coyote
|
||||
* [Getting Started](#getting-started): Get started with Coyote by doing first-run setup steps.
|
||||
* [Sharing Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Sharing-Configurations): Install bundles of agents, roles, macros, tools, and MCP servers from any git repo, and share your own.
|
||||
* [REPL](https://github.com/Dark-Alex-17/coyote/wiki/REPL): Interactive Read-Eval-Print Loop for conversational interactions with LLMs and Coyote.
|
||||
* [Custom REPL Prompt](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt): Customize the REPL prompt to provide useful contextual information.
|
||||
* [Vault](https://github.com/Dark-Alex-17/coyote/wiki/Vault): Securely store and manage sensitive information such as API keys and credentials.
|
||||
* [Shell Integrations](https://github.com/Dark-Alex-17/coyote/wiki/Shell-Integrations): Seamlessly integrate Coyote with your shell environment for enhanced command-line assistance.
|
||||
* [Function Calling](https://github.com/Dark-Alex-17/coyote/wiki/Tools): Leverage function calling capabilities to extend Coyote's functionality with custom tools
|
||||
* [Creating Custom Tools](https://github.com/Dark-Alex-17/coyote/wiki/Custom-Tools): You can create your own custom tools to enhance Coyote's capabilities.
|
||||
* [Create Custom Python Tools](https://github.com/Dark-Alex-17/coyote/wiki/Custom-Tools#custom-python-based-tools)
|
||||
* [Create Custom TypeScript Tools](https://github.com/Dark-Alex-17/coyote/wiki/Custom-Tools#custom-typescript-based-tools)
|
||||
* [Create Custom Bash Tools](https://github.com/Dark-Alex-17/coyote/wiki/Custom-Bash-Tools)
|
||||
* [Bash Prompt Utilities](https://github.com/Dark-Alex-17/coyote/wiki/Bash-Prompt-Helpers)
|
||||
* [First-Class MCP Server Support](https://github.com/Dark-Alex-17/coyote/wiki/MCP-Servers): Easily connect and interact with MCP servers for advanced functionality.
|
||||
* [Macros](https://github.com/Dark-Alex-17/coyote/wiki/Macros): Automate repetitive tasks and workflows with Coyote "scripts" (macros).
|
||||
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
|
||||
* [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
|
||||
* [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains.
|
||||
* [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
|
||||
* [Graph Agents](https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents): Define an agent as a declarative, YAML-driven workflow. A directed graph of typed nodes (LLM calls, scripts, approvals, user input, RAG retrieval, sub-agent spawns).
|
||||
* [Todo System](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System): Built-in task tracking for improved LLM reliability with smaller models.
|
||||
* [Environment Variables](https://github.com/Dark-Alex-17/coyote/wiki/Environment-Variables): Override and customize your Coyote configuration at runtime with environment variables.
|
||||
* [Client Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Clients): Configuration instructions for various LLM providers.
|
||||
* [Authentication (API Key & OAuth)](https://github.com/Dark-Alex-17/coyote/wiki/Clients#authentication): Authenticate with API keys or OAuth for subscription-based access.
|
||||
* [Patching API Requests](https://github.com/Dark-Alex-17/coyote/wiki/Patches): Learn how to patch API requests for advanced customization.
|
||||
* [Custom Themes](https://github.com/Dark-Alex-17/coyote/wiki/Themes): Change the look and feel of Coyote to your preferences with custom themes.
|
||||
* [History](#history): A history of how Coyote came to be.
|
||||
|
||||
## Prerequisites
|
||||
Loki requires the following tools to be installed on your system:
|
||||
Coyote 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 `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:
|
||||
```sh
|
||||
# ~/.bashrc or ~/.zshrc
|
||||
export JIRA_API_TOKEN="your_jira_api_token_here"
|
||||
```
|
||||
* Then run `jira init`, select installation type as `cloud`, and provide the required details to generate a config
|
||||
file for the Jira CLI.
|
||||
* [usql](https://github.com/xo/usql) (For the `sql` agent)
|
||||
* `brew install xo/xo/usql`
|
||||
* [docker](https://docs.docker.com/engine/install/)
|
||||
* [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
* `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
|
||||
These tools are used to provide various functionalities within Loki, such as document processing, JSON manipulation,
|
||||
interaction with Jira, and they are used within agents and tools.
|
||||
These tools are used to provide various functionalities within Coyote, such as document processing, JSON manipulation,
|
||||
etc., and they are used within agents and tools.
|
||||
|
||||
## Install
|
||||
|
||||
### Cargo
|
||||
If you have Cargo installed, then you can install `loki` from Crates.io:
|
||||
If you have Cargo installed, then you can install `coyote` from Crates.io:
|
||||
|
||||
```shell
|
||||
cargo install loki-ai # Binary name is `loki`
|
||||
cargo install coyote-ai # Binary name is `coyote`
|
||||
|
||||
# If you encounter issues installing, try installing with '--locked'
|
||||
cargo install --locked loki-ai
|
||||
cargo install --locked coyote-ai
|
||||
```
|
||||
|
||||
### Homebrew (Mac/Linux)
|
||||
To install Loki from Homebrew, install the `loki` tap. Then you'll be able to install `loki`:
|
||||
To install Coyote from Homebrew, install the `coyote` tap. Then you'll be able to install `coyote`:
|
||||
|
||||
```shell
|
||||
brew tap Dark-Alex-17/loki
|
||||
brew install loki
|
||||
brew tap Dark-Alex-17/coyote
|
||||
brew install coyote
|
||||
|
||||
# If you need to be more specific, use:
|
||||
brew install Dark-Alex-17/loki/loki
|
||||
brew install Dark-Alex-17/coyote/coyote
|
||||
```
|
||||
|
||||
To upgrade `loki` using Homebrew:
|
||||
To upgrade `coyote` using Homebrew:
|
||||
|
||||
```shell
|
||||
brew upgrade loki
|
||||
brew upgrade coyote
|
||||
```
|
||||
|
||||
### Scripts
|
||||
#### Linux/MacOS (`bash`)
|
||||
You can use the following command to run a bash script that downloads and installs the latest version of `loki` for your
|
||||
You can use the following command to run a bash script that downloads and installs the latest version of `coyote` for your
|
||||
OS (Linux/MacOS) and architecture (x86_64/arm64):
|
||||
|
||||
```shell
|
||||
curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/loki/main/install_loki.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/coyote/main/install_coyote.sh | bash
|
||||
```
|
||||
|
||||
#### Windows/Linux/MacOS (`PowerShell`)
|
||||
You can use the following command to run a PowerShell script that downloads and installs the latest version of `loki`
|
||||
You can use the following command to run a PowerShell script that downloads and installs the latest version of `coyote`
|
||||
for your OS (Windows/Linux/MacOS) and architecture (x86_64/arm64):
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/Dark-Alex-17/loki/main/scripts/install_loki.ps1 | iex"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/Dark-Alex-17/coyote/main/scripts/install_coyote.ps1 | iex"
|
||||
```
|
||||
|
||||
### Manual
|
||||
Binaries are available on the [releases](https://github.com/Dark-Alex-17/loki/releases) page for the following platforms:
|
||||
Binaries are available on the [releases](https://github.com/Dark-Alex-17/coyote/releases) page for the following platforms:
|
||||
|
||||
| Platform | Architecture(s) |
|
||||
|----------------|-----------------|
|
||||
@@ -125,35 +118,58 @@ Binaries are available on the [releases](https://github.com/Dark-Alex-17/loki/re
|
||||
#### Windows Instructions
|
||||
To use a binary from the releases page on Windows, do the following:
|
||||
|
||||
1. Download the latest [binary](https://github.com/Dark-Alex-17/loki/releases) for your OS.
|
||||
1. Download the latest [binary](https://github.com/Dark-Alex-17/coyote/releases) for your OS.
|
||||
2. Use 7-Zip or TarTool to unpack the Tar file.
|
||||
3. Run the executable `loki.exe`!
|
||||
3. Run the executable `coyote.exe`!
|
||||
|
||||
#### Linux/MacOS Instructions
|
||||
To use a binary from the releases page on Linux/MacOS, do the following:
|
||||
|
||||
1. Download the latest [binary](https://github.com/Dark-Alex-17/loki/releases) for your OS.
|
||||
1. Download the latest [binary](https://github.com/Dark-Alex-17/coyote/releases) for your OS.
|
||||
2. `cd` to the directory where you downloaded the binary.
|
||||
3. Extract the binary with `tar -C /usr/local/bin -xzf loki-<arch>.tar.gz` (Note: This may require `sudo`)
|
||||
4. Now you can run `loki`!
|
||||
3. Extract the binary with `tar -C /usr/local/bin -xzf coyote-<arch>.tar.gz` (Note: This may require `sudo`)
|
||||
4. Now you can run `coyote`!
|
||||
|
||||
## Updating
|
||||
Coyote can update itself in place to the latest GitHub release. Run `coyote --update`
|
||||
for the newest release, or `coyote --update v0.4.0` for a specific version:
|
||||
|
||||
```shell
|
||||
coyote --update
|
||||
coyote --update v0.4.0
|
||||
```
|
||||
|
||||
The same is available from within the REPL via `.update` and `.update v0.4.0`.
|
||||
|
||||
If Coyote was installed with a package manager, prefer that package manager so its
|
||||
records stay in sync with the binary on disk; i.e. `brew upgrade coyote` for Homebrew,
|
||||
or `cargo install --locked coyote-ai` for Cargo.
|
||||
|
||||
When Coyote detects a package-manager install it prints a warning and asks for
|
||||
confirmation. In a non-interactive shell (no TTY), pass `--force` to update
|
||||
anyway:
|
||||
|
||||
```shell
|
||||
coyote --update --force
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
After installation, you can generate the configuration files and directories by simply running:
|
||||
|
||||
```sh
|
||||
loki --info
|
||||
coyote --info
|
||||
```
|
||||
|
||||
Then, you need to set up the Loki vault by creating a vault password file. Loki will do this for you automatically and
|
||||
Then, you need to set up the Coyote vault by creating a vault password file. Coyote will do this for you automatically and
|
||||
guide you through the process when you first attempt to access the vault. So, to get started, you can run:
|
||||
|
||||
```sh
|
||||
loki --list-secrets
|
||||
coyote --list-secrets
|
||||
```
|
||||
|
||||
### Authentication
|
||||
Each client in your configuration needs authentication (with a few exceptions; e.g. ollama). Most clients use an API key
|
||||
(set via `api_key` in the config or through the [vault](https://github.com/Dark-Alex-17/loki/wiki/Vault)). For providers that support OAuth (e.g. Claude Pro/Max
|
||||
(set via `api_key` in the config or through the [vault](https://github.com/Dark-Alex-17/coyote/wiki/Vault)). For providers that support OAuth (e.g. Claude Pro/Max
|
||||
subscribers, Google Gemini), you can authenticate with your existing subscription instead:
|
||||
|
||||
```yaml
|
||||
@@ -165,40 +181,40 @@ clients:
|
||||
```
|
||||
|
||||
```sh
|
||||
loki --authenticate my-claude-oauth
|
||||
coyote --authenticate my-claude-oauth
|
||||
# Or via the REPL: .authenticate
|
||||
```
|
||||
|
||||
For full details, see the [authentication documentation](https://github.com/Dark-Alex-17/loki/wiki/Clients#authentication).
|
||||
For full details, see the [authentication documentation](https://github.com/Dark-Alex-17/coyote/wiki/Clients#authentication).
|
||||
|
||||
### Tab-Completions
|
||||
You can also enable tab completions to make using Loki easier. To do so, add the following to your shell profile:
|
||||
You can also enable tab completions to make using Coyote easier. To do so, add the following to your shell profile:
|
||||
```shell
|
||||
# Bash
|
||||
# (add to: `~/.bashrc`)
|
||||
source <(COMPLETE=bash loki)
|
||||
source <(COMPLETE=bash coyote)
|
||||
|
||||
# Zsh
|
||||
# (add to: `~/.zshrc`)
|
||||
source <(COMPLETE=zsh loki)
|
||||
source <(COMPLETE=zsh coyote)
|
||||
|
||||
# Fish
|
||||
# (add to: `~/.config/fish/config.fish`)
|
||||
source <(COMPLETE=fish loki | psub)
|
||||
source <(COMPLETE=fish coyote | psub)
|
||||
|
||||
# Elvish
|
||||
# (add to: `~/.elvish/rc.elv`)
|
||||
eval (E:COMPLETE=elvish loki | slurp)
|
||||
eval (E:COMPLETE=elvish coyote | slurp)
|
||||
|
||||
# PowerShell
|
||||
# (add to: `$PROFILE`)
|
||||
$env:COMPLETE = "powershell"
|
||||
loki | Out-String | Invoke-Expression
|
||||
coyote | Out-String | Invoke-Expression
|
||||
```
|
||||
|
||||
### Shell Integration
|
||||
You can integrate Loki's Shell Assistant into your shell for enhanced command-line assistance. Add the code in the
|
||||
corresponding [shell integration script](./scripts/shell-integration) to your shell. Then, you can invoke Loki to convert natural language to
|
||||
You can integrate Coyote's Shell Assistant into your shell for enhanced command-line assistance. Add the code in the
|
||||
corresponding [shell integration script](./scripts/shell-integration) to your shell. Then, you can invoke Coyote to convert natural language to
|
||||
shell commands by pressing `Alt-e`. For example:
|
||||
|
||||
```shell
|
||||
@@ -208,18 +224,18 @@ find . -name "*.md"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
The location of the global Loki configuration varies between systems, so you can use the following command to find your
|
||||
The location of the global Coyote configuration varies between systems, so you can use the following command to find your
|
||||
`config.yaml` file:
|
||||
|
||||
```shell
|
||||
loki --info | grep 'config_file' | awk '{print $2}'
|
||||
coyote --info | grep 'config_file' | awk '{print $2}'
|
||||
```
|
||||
|
||||
The configuration file consists of a number of settings. To see a full example configuration file with every setting
|
||||
defined, refer to the [example configuration file](./config.example.yaml).
|
||||
|
||||
### Default LLM
|
||||
The following settings are available to configure the default LLM that is used when you start Loki, and its
|
||||
The following settings are available to configure the default LLM that is used when you start Coyote, and its
|
||||
hyperparameters:
|
||||
|
||||
| Setting | Description |
|
||||
@@ -229,34 +245,34 @@ hyperparameters:
|
||||
| `top_p` | The default `top_p` hyperparameter value to use for all models, with a range of (0,1) (or (0,2) for some models); <br>Used unless explicitly overridden |
|
||||
|
||||
### CLI Behavior
|
||||
You can use the following settings to modify the behavior of Loki:
|
||||
You can use the following settings to modify the behavior of Coyote:
|
||||
|
||||
| Setting | Default Value | Description |
|
||||
|---------------|---------------|-------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `stream` | `true` | Controls whether to use stream-style APIs when querying for completions from LLM providers |
|
||||
| `save` | `true` | Controls whether to save each query/response to every model to `messages.md` for posterity; Useful for debugging |
|
||||
| `keybindings` | `emacs` | Specifies which keybinding schema to use; can either be `emacs` or `vi` |
|
||||
| `editor` | `null` | What text editor Loki should use to edit the input buffer or session (e.g. `vim`, `emacs`, `nano`, `hx`); <br>Defaults to `$EDITOR` |
|
||||
| `editor` | `null` | What text editor Coyote should use to edit the input buffer or session (e.g. `vim`, `emacs`, `nano`, `hx`); <br>Defaults to `$EDITOR` |
|
||||
| `wrap` | `no` | Controls whether text is wrapped (can be `no`, `auto`, or some `<max_width>` |
|
||||
| `wrap_code` | `false` | Enables or disables the wrapping of code blocks |
|
||||
|
||||
### Preludes
|
||||
Preludes let you define the default behavior for the different operating modes of Loki. The available settings are
|
||||
Preludes let you define the default behavior for the different operating modes of Coyote. The available settings are
|
||||
shown below:
|
||||
|
||||
| Setting | Description |
|
||||
|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `repl_prelude` | This setting lets you specify a default `session` or `role` to use when starting Loki in [REPL](https://github.com/Dark-Alex-17/loki/wiki/REPL) mode. <br>Values can be <ul><li>`role:<name>` to define a role</li><li>`session:<name>` to define a session</li><li>`<session>:<role>` to define both a session and a role to use</li></ul> |
|
||||
| `cmd_prelude` | This setting lets you specify a default `session` or `role` to use when running one-off queries in Loki via the CLI. <br>Values can be <ul><li>`role:<name>` to define a role</li><li>`session:<name>` to define a session</li><li>`<session>:<role>` to define both a session and a role to use</li></ul> |
|
||||
| `repl_prelude` | This setting lets you specify a default `session` or `role` to use when starting Coyote in [REPL](https://github.com/Dark-Alex-17/coyote/wiki/REPL) mode. <br>Values can be <ul><li>`role:<name>` to define a role</li><li>`session:<name>` to define a session</li><li>`<session>:<role>` to define both a session and a role to use</li></ul> |
|
||||
| `cmd_prelude` | This setting lets you specify a default `session` or `role` to use when running one-off queries in Coyote via the CLI. <br>Values can be <ul><li>`role:<name>` to define a role</li><li>`session:<name>` to define a session</li><li>`<session>:<role>` to define both a session and a role to use</li></ul> |
|
||||
| `agent_session` | This setting is used to specify a default session that all agents should start into, unless otherwise specified in the agent configuration. (e.g. `temp`, `default`) |
|
||||
|
||||
### Appearance
|
||||
The appearance of Loki can be modified using the following settings:
|
||||
The appearance of Coyote can be modified using the following settings:
|
||||
|
||||
| Setting | Default Value | Description |
|
||||
|---------------|---------------|------------------------------------------------------|
|
||||
| `highlight` | `true` | This setting enables or disables syntax highlighting |
|
||||
| `light_theme` | `false` | This setting toggles light mode in Loki |
|
||||
| `light_theme` | `false` | This setting toggles light mode in Coyote |
|
||||
|
||||
### Miscellaneous Settings
|
||||
| Setting | Default Value | Description |
|
||||
@@ -268,7 +284,7 @@ The appearance of Loki can be modified using the following settings:
|
||||
|
||||
## History
|
||||
|
||||
Loki began as a fork of [AIChat CLI](https://github.com/sigoden/aichat) and has since evolved into an independent project.
|
||||
Coyote 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.
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ set -euo pipefail
|
||||
#######################
|
||||
|
||||
# Cache file name for detected project info
|
||||
_LOKI_PROJECT_CACHE=".loki-project.json"
|
||||
_COYOTE_PROJECT_CACHE=".coyote-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}"
|
||||
local cache_file="${dir}/${_COYOTE_PROJECT_CACHE}"
|
||||
|
||||
if [[ -f "${cache_file}" ]]; then
|
||||
local cached
|
||||
@@ -32,7 +32,7 @@ _read_project_cache() {
|
||||
_write_project_cache() {
|
||||
local dir="$1"
|
||||
local json="$2"
|
||||
local cache_file="${dir}/${_LOKI_PROJECT_CACHE}"
|
||||
local cache_file="${dir}/${_COYOTE_PROJECT_CACHE}"
|
||||
|
||||
echo "${json}" > "${cache_file}" 2>/dev/null || true
|
||||
}
|
||||
@@ -238,7 +238,7 @@ _detect_with_llm() {
|
||||
)
|
||||
|
||||
local llm_response
|
||||
llm_response=$(loki --no-stream "${prompt}" 2>/dev/null) || return 1
|
||||
llm_response=$(coyote --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)
|
||||
|
||||
@@ -1,40 +1,82 @@
|
||||
# Coder
|
||||
|
||||
An AI agent that assists you with your coding tasks.
|
||||
A graph-based implementation agent. Plans, implements, and runs build +
|
||||
tests in a bounded fix-loop until verified. Designed to be delegated to by
|
||||
the **[Sisyphus](../sisyphus/README.md)** agent.
|
||||
|
||||
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.
|
||||
Coder is a [graph agent](https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents): its workflow is
|
||||
defined declaratively in `graph.yaml`, with verification and the
|
||||
implement-fix loop enforced as graph edges rather than prose.
|
||||
|
||||
## Features
|
||||
## Workflow
|
||||
|
||||
- 🏗️ Intelligent project structure creation and management
|
||||
- 🖼️ Convert screenshots into clean, functional code
|
||||
- 📁 Comprehensive file system operations (create folders, files, read/write files)
|
||||
- 🧐 Advanced code analysis and improvement suggestions
|
||||
- 📊 Precise diff-based file editing for controlled code modifications
|
||||
```
|
||||
analyze_request (llm + output_schema) plan + complexity extraction
|
||||
↓
|
||||
route_complexity (script) opt-out approval gate (complexity ≥ 7)
|
||||
↓
|
||||
gate_approval (approval, optional)
|
||||
↓
|
||||
implement (llm + fs tools) actual file edits
|
||||
↓
|
||||
verify_build (script)
|
||||
↓
|
||||
verify_tests (script)
|
||||
↓
|
||||
fix_loop_gate (script) back-edge to implement (bounded)
|
||||
↓
|
||||
end_success / end_rejected / end_failure
|
||||
```
|
||||
|
||||
It can also be used as a standalone tool for direct coding assistance.
|
||||
End nodes emit one of three sentinel outcomes for the caller:
|
||||
|
||||
## Pro-Tip: Use an IDE MCP Server for Improved Performance
|
||||
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
|
||||
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
|
||||
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure
|
||||
them), and modify the agent definition to look like this:
|
||||
- `CODER_COMPLETE` — build and tests passed.
|
||||
- `CODER_REJECTED` — user rejected the plan at the approval gate.
|
||||
- `CODER_FAILED` — fix-loop exhausted; build/tests still failing.
|
||||
|
||||
## Tuning
|
||||
|
||||
The agent's `project_dir` is exposed via the standard `variables:` block,
|
||||
so it accepts the runtime override flag:
|
||||
|
||||
```sh
|
||||
# Invoke from inside the project (project_dir defaults to ".")
|
||||
cd /path/to/your/project
|
||||
coyote -a coder "Add a foo() function..."
|
||||
|
||||
# Or invoke from anywhere with an explicit override
|
||||
coyote -a coder --agent-variable project_dir /path/to/your/project "Add..."
|
||||
```
|
||||
|
||||
`graph.yaml` `initial_state` exposes:
|
||||
|
||||
- `max_fix_attempts` (default `3`) — fix-loop budget before `end_failure`.
|
||||
|
||||
Environment overrides honored by the script nodes:
|
||||
|
||||
- `BUILD_CMD` — skip project-type detection for the build/check command.
|
||||
- `TEST_CMD` — skip detection for tests.
|
||||
- `CODER_AUTOAPPROVE=1` — bypass the approval gate (for non-interactive runs
|
||||
where complexity might trip the gate).
|
||||
|
||||
## Pro-Tip: IDE MCP Server
|
||||
|
||||
Modern IDEs (JetBrains, VS Code, Cursor, Zed, etc.) expose MCP servers
|
||||
that let LLMs use IDE tools directly. To wire one in, edit `graph.yaml`:
|
||||
|
||||
```yaml
|
||||
# ...
|
||||
|
||||
mcp_servers:
|
||||
- jetbrains # The name of your configured IDE MCP server
|
||||
- your-ide-mcp-server
|
||||
|
||||
global_tools:
|
||||
# Keep useful read-only tools for reading files in other non-project directories
|
||||
# Keep read-only fs tools for files outside the IDE project
|
||||
- fs_read.sh
|
||||
- fs_grep.sh
|
||||
- fs_glob.sh
|
||||
# - fs_write.sh
|
||||
# - fs_patch.sh
|
||||
- execute_command.sh
|
||||
```
|
||||
|
||||
# ...
|
||||
```
|
||||
Then add the MCP server's write/patch tools to the `implement` node's
|
||||
`tools:` whitelist.
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
name: coder
|
||||
description: Implementation agent - writes code, follows patterns, verifies with builds
|
||||
version: 1.0.0
|
||||
temperature: 0.1
|
||||
|
||||
auto_continue: true
|
||||
max_auto_continues: 15
|
||||
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_write.sh
|
||||
- fs_patch.sh
|
||||
- execute_command.sh
|
||||
|
||||
instructions: |
|
||||
You are a senior engineer. You write code that works on the first try.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Given an implementation task:
|
||||
1. Check for orchestrator context first (see below)
|
||||
2. Fill gaps only. Read files NOT already covered in context
|
||||
3. Write the code (using tools, NOT chat output)
|
||||
4. Verify it compiles/builds
|
||||
5. Signal completion with a summary
|
||||
|
||||
## Using Orchestrator Context (IMPORTANT)
|
||||
|
||||
When spawned by sisyphus, your prompt will often contain a `<context>` block
|
||||
with prior findings: file paths, code patterns, and conventions discovered by
|
||||
explore agents.
|
||||
|
||||
**If context is provided:**
|
||||
1. Use it as your primary reference. Don't re-read files already summarized
|
||||
2. Follow the code patterns shown. Snippets in context ARE the style guide
|
||||
3. Read the referenced files ONLY IF you need more detail (e.g. full function
|
||||
signature, import list, or adjacent code not included in the snippet)
|
||||
4. If context includes a "Conventions" section, follow it exactly
|
||||
|
||||
**If context is NOT provided or is too vague to act on:**
|
||||
Fall back to self-exploration: grep for similar files, read 1-2 examples,
|
||||
match their style.
|
||||
|
||||
**Never ignore provided context.** It represents work already done upstream.
|
||||
|
||||
## Todo System
|
||||
|
||||
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
|
||||
|
||||
## Writing Code
|
||||
|
||||
**CRITICAL**: Write code using `write_file` tool, NEVER paste code in chat.
|
||||
|
||||
Correct:
|
||||
```
|
||||
write_file --path "src/user.rs" --content "pub struct User { ... }"
|
||||
```
|
||||
|
||||
Wrong:
|
||||
```
|
||||
Here's the implementation:
|
||||
\`\`\`rust
|
||||
pub struct User { ... }
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
When done, end your response with a summary so the parent agent knows what happened:
|
||||
|
||||
```
|
||||
CODER_COMPLETE: [summary of what was implemented, which files were created/modified, and build status]
|
||||
```
|
||||
|
||||
Or if something went wrong:
|
||||
```
|
||||
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__}}
|
||||
|
||||
## Available tools:
|
||||
{{__tools__}}
|
||||
@@ -0,0 +1,278 @@
|
||||
name: coder
|
||||
description: |
|
||||
Implementation agent. Plans, implements, and runs build + tests in a
|
||||
bounded fix-loop until verified. Designed to be delegated to by sisyphus.
|
||||
version: "1.0"
|
||||
|
||||
temperature: 0.1
|
||||
|
||||
global_tools:
|
||||
- fs_cat.sh
|
||||
- fs_ls.sh
|
||||
- fs_write.sh
|
||||
- fs_patch.sh
|
||||
- execute_command.sh
|
||||
|
||||
variables:
|
||||
- name: project_dir
|
||||
description: |
|
||||
Absolute path to the project directory. Defaults to "." which is the
|
||||
directory you invoked `coyote` from. Override at runtime with
|
||||
`coyote -a coder --agent-variable project_dir /abs/path "..."`.
|
||||
default: "."
|
||||
|
||||
settings:
|
||||
max_loop_iterations: 20
|
||||
log_state_snapshots: true
|
||||
validate_before_run: true
|
||||
timeout: 1800
|
||||
|
||||
initial_state:
|
||||
project_dir: ""
|
||||
fix_attempts: 0
|
||||
max_fix_attempts: 3
|
||||
fix_instructions: ""
|
||||
build_output: ""
|
||||
tests_output: ""
|
||||
last_node_output: ""
|
||||
plan_summary: ""
|
||||
files_to_modify: []
|
||||
files_to_create: []
|
||||
risks: []
|
||||
complexity_score: 0
|
||||
|
||||
start: resolve_paths
|
||||
|
||||
nodes:
|
||||
resolve_paths:
|
||||
id: resolve_paths
|
||||
type: script
|
||||
description: Resolve project_dir to an absolute path from the agent variable
|
||||
script: scripts/resolve_paths.sh
|
||||
timeout: 5
|
||||
fallback: end_failure
|
||||
|
||||
analyze_request:
|
||||
id: analyze_request
|
||||
type: llm
|
||||
description: Extract a structured plan and complexity score from the orchestrator's prompt
|
||||
instructions: |
|
||||
You are a senior engineer's planning assistant. Read the orchestrator's
|
||||
request and emit a structured plan. You only plan. You never edit files.
|
||||
|
||||
Score complexity from 1 to 10:
|
||||
1-3: trivial - single file, <=20 lines changed, obvious approach
|
||||
4-6: moderate - 2-5 files, clear approach, some pattern matching
|
||||
7-10: complex - multi-component, ambiguous tradeoffs, refactoring,
|
||||
or wide blast radius
|
||||
|
||||
Be specific in `files_to_modify` and `files_to_create`. All paths
|
||||
MUST be absolute. The project root is {{project_dir}}. Prefer paths
|
||||
like "{{project_dir}}/src/foo.rs" over "src/foo.rs". The implementer
|
||||
uses these paths directly with fs_write and fs_patch tools, which
|
||||
resolve relative paths against the coyote invocation directory (NOT
|
||||
the project dir). Empty arrays are fine if no files in that category.
|
||||
|
||||
`risks` is a list of short strings. Anything that could derail the
|
||||
implementation: unknown dependencies, brittle tests, blast radius,
|
||||
etc. Empty list is fine.
|
||||
|
||||
Project directory: {{project_dir}}
|
||||
prompt: "{{initial_prompt}}"
|
||||
tools: []
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
plan_summary:
|
||||
type: string
|
||||
description: 1-3 sentences summarizing what will be done
|
||||
files_to_modify:
|
||||
type: array
|
||||
items: {type: string}
|
||||
files_to_create:
|
||||
type: array
|
||||
items: {type: string}
|
||||
complexity_score:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 10
|
||||
risks:
|
||||
type: array
|
||||
items: {type: string}
|
||||
required: [plan_summary, files_to_modify, files_to_create, complexity_score, risks]
|
||||
state_updates:
|
||||
last_node_output: "{{output}}"
|
||||
fallback: end_failure
|
||||
next: route_complexity
|
||||
|
||||
route_complexity:
|
||||
id: route_complexity
|
||||
type: script
|
||||
description: Route to approval gate for complex plans; skip otherwise
|
||||
script: scripts/route_complexity.sh
|
||||
timeout: 5
|
||||
fallback: implement
|
||||
|
||||
gate_approval:
|
||||
id: gate_approval
|
||||
type: approval
|
||||
description: Optional human checkpoint for high-complexity plans
|
||||
question: |
|
||||
## Plan
|
||||
{{plan_summary}}
|
||||
|
||||
## Files to modify
|
||||
{{files_to_modify}}
|
||||
|
||||
## Files to create
|
||||
{{files_to_create}}
|
||||
|
||||
## Risks
|
||||
{{risks}}
|
||||
|
||||
Complexity: {{complexity_score}}/10
|
||||
|
||||
Approve this plan?
|
||||
options:
|
||||
- "yes"
|
||||
- "no"
|
||||
routes:
|
||||
"yes": implement
|
||||
"no": end_rejected
|
||||
on_other: end_rejected
|
||||
|
||||
implement:
|
||||
id: implement
|
||||
type: llm
|
||||
description: Write code via fs tools. Bounded tool-call loop.
|
||||
instructions: |
|
||||
You are a senior engineer. Implement the plan by writing code via
|
||||
tools. Follow existing patterns in the codebase.
|
||||
|
||||
## Writing code
|
||||
|
||||
1. Use `fs_patch` for surgical edits to existing files.
|
||||
2. Use `fs_write` for new files or full rewrites.
|
||||
3. NEVER output code to chat. Always use tools.
|
||||
4. ALWAYS pass ABSOLUTE paths to fs_write and fs_patch. Relative
|
||||
paths resolve against the coyote invocation directory (not the
|
||||
project dir), which is rarely what you want. The project root
|
||||
is {{project_dir}}.
|
||||
|
||||
## File reading
|
||||
|
||||
1. Use `execute_command` to grep/find:
|
||||
`execute_command --command "grep -rn 'fn handle_request' --include='*.rs' ."`
|
||||
`execute_command --command "find . -name '*.rs' -not -path '*/target/*'"`
|
||||
2. Read only what you need:
|
||||
`fs_cat --path "src/main.rs" --offset 50 --limit 30`
|
||||
3. Never read entire large files. Use offset/limit.
|
||||
4. Use `fs_ls` to list directory contents.
|
||||
|
||||
## Pattern matching
|
||||
|
||||
Before writing ANY file:
|
||||
1. Find a similar existing file (grep, then read).
|
||||
2. Match its style: imports, naming, structure, error handling.
|
||||
3. Follow the same patterns exactly. Do not invent new ones.
|
||||
|
||||
## Fix loop
|
||||
|
||||
If the "Fix loop status" section in your user prompt is non-empty,
|
||||
the previous attempt failed verification. Read the error, identify
|
||||
the minimal fix, apply it. Do not refactor while fixing.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Match existing patterns - read examples first.
|
||||
2. Minimal changes - implement only what's asked.
|
||||
3. Never suppress errors (`as any`, `@ts-ignore`, `#[allow(...)]`
|
||||
on unfamiliar lints, etc.).
|
||||
4. No dead code, no commented-out blocks, no premature abstractions.
|
||||
5. End your turn when editing is done. The graph runs verification next.
|
||||
|
||||
Project directory: {{project_dir}}
|
||||
prompt: |
|
||||
## Plan summary
|
||||
{{plan_summary}}
|
||||
|
||||
## Files involved
|
||||
- Modify: {{files_to_modify}}
|
||||
- Create: {{files_to_create}}
|
||||
|
||||
## Original request from the orchestrator
|
||||
{{initial_prompt}}
|
||||
|
||||
## Fix loop status
|
||||
{{fix_instructions}}
|
||||
tools:
|
||||
- fs_cat
|
||||
- fs_ls
|
||||
- fs_write
|
||||
- fs_patch
|
||||
- execute_command
|
||||
max_iterations: 30
|
||||
state_updates:
|
||||
last_node_output: "{{output}}"
|
||||
fallback: end_failure
|
||||
next: verify_build
|
||||
|
||||
verify_build:
|
||||
id: verify_build
|
||||
type: script
|
||||
description: Run the project's check/build command. Routes to verify_tests on success, fix_loop_gate on failure.
|
||||
script: scripts/verify_build.sh
|
||||
timeout: 300
|
||||
fallback: fix_loop_gate
|
||||
|
||||
verify_tests:
|
||||
id: verify_tests
|
||||
type: script
|
||||
description: Run the project's test command. Routes to end_success on pass, fix_loop_gate on failure.
|
||||
script: scripts/verify_tests.sh
|
||||
timeout: 600
|
||||
fallback: fix_loop_gate
|
||||
|
||||
fix_loop_gate:
|
||||
id: fix_loop_gate
|
||||
type: script
|
||||
description: Budget gate. Loops back to implement with fix_instructions populated, or terminates as end_failure.
|
||||
script: scripts/fix_loop_gate.sh
|
||||
timeout: 5
|
||||
fallback: end_failure
|
||||
|
||||
end_success:
|
||||
id: end_success
|
||||
type: end
|
||||
output: |
|
||||
CODER_COMPLETE
|
||||
Plan: {{plan_summary}}
|
||||
Files modified: {{files_to_modify}}
|
||||
Files created: {{files_to_create}}
|
||||
Build: passed
|
||||
Tests: passed
|
||||
|
||||
end_rejected:
|
||||
id: end_rejected
|
||||
type: end
|
||||
output: |
|
||||
CODER_REJECTED
|
||||
Plan was rejected at the approval gate.
|
||||
Plan: {{plan_summary}}
|
||||
|
||||
end_failure:
|
||||
id: end_failure
|
||||
type: end
|
||||
output: |
|
||||
CODER_FAILED
|
||||
Plan: {{plan_summary}}
|
||||
Attempts: {{fix_attempts}}/{{max_fix_attempts}}
|
||||
|
||||
Last node output:
|
||||
{{last_node_output}}
|
||||
|
||||
Last build output:
|
||||
{{build_output}}
|
||||
|
||||
Last tests output:
|
||||
{{tests_output}}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -n "${GRAPH_STATE_FILE:-}" ]]; then
|
||||
state=$(cat "$GRAPH_STATE_FILE")
|
||||
elif [[ -n "${GRAPH_STATE:-}" ]]; then
|
||||
state="$GRAPH_STATE"
|
||||
else
|
||||
state='{}'
|
||||
fi
|
||||
|
||||
fix_attempts=$(echo "$state" | jq -r '.fix_attempts // 0')
|
||||
max_fix_attempts=$(echo "$state" | jq -r '.max_fix_attempts // 3')
|
||||
build_ok=$(echo "$state" | jq -r '.build_ok | if . == null then "true" else (. | tostring) end')
|
||||
tests_ok=$(echo "$state" | jq -r '.tests_ok | if . == null then "true" else (. | tostring) end')
|
||||
build_output=$(echo "$state" | jq -r '.build_output // ""')
|
||||
tests_output=$(echo "$state" | jq -r '.tests_output // ""')
|
||||
|
||||
if (( fix_attempts >= max_fix_attempts )); then
|
||||
jq -nc \
|
||||
--argjson n "$fix_attempts" \
|
||||
'{
|
||||
"fix_attempts": $n,
|
||||
"_next": "end_failure"
|
||||
}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
next_attempts=$((fix_attempts + 1))
|
||||
|
||||
if [[ "$build_ok" != "true" ]]; then
|
||||
fix_instructions=$(printf '## Fix loop status (attempt %d of %d)\n\nThe previous attempt failed the build.\n\nBuild output:\n```\n%s\n```\n\nIdentify the minimal fix and apply it. Do not refactor.' \
|
||||
"$next_attempts" "$max_fix_attempts" "$build_output")
|
||||
elif [[ "$tests_ok" != "true" ]]; then
|
||||
fix_instructions=$(printf '## Fix loop status (attempt %d of %d)\n\nBuild passed but tests failed.\n\nTest output:\n```\n%s\n```\n\nIdentify the minimal fix and apply it. Do not refactor.' \
|
||||
"$next_attempts" "$max_fix_attempts" "$tests_output")
|
||||
else
|
||||
fix_instructions=$(printf '## Fix loop status (attempt %d of %d)\n\nfix_loop_gate was reached but no failure was detected in state. Re-run the verification step.' \
|
||||
"$next_attempts" "$max_fix_attempts")
|
||||
fi
|
||||
|
||||
jq -nc \
|
||||
--argjson n "$next_attempts" \
|
||||
--arg fi "$fix_instructions" \
|
||||
'{
|
||||
"fix_attempts": $n,
|
||||
"fix_instructions": $fi,
|
||||
"_next": "implement"
|
||||
}'
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
project_dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
|
||||
resolved=$(cd "$project_dir" 2>/dev/null && pwd) || resolved="$project_dir"
|
||||
|
||||
jq -nc \
|
||||
--arg pd "$resolved" \
|
||||
'{
|
||||
"project_dir": $pd,
|
||||
"_next": "analyze_request"
|
||||
}'
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -n "${GRAPH_STATE_FILE:-}" ]]; then
|
||||
state=$(cat "$GRAPH_STATE_FILE")
|
||||
elif [[ -n "${GRAPH_STATE:-}" ]]; then
|
||||
state="$GRAPH_STATE"
|
||||
else
|
||||
state='{}'
|
||||
fi
|
||||
|
||||
complexity=$(echo "$state" | jq -r '.complexity_score // 0')
|
||||
|
||||
if [[ "${CODER_AUTOAPPROVE:-0}" == "1" ]]; then
|
||||
jq -nc '{"_next": "implement"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if (( complexity >= 7 )); then
|
||||
jq -nc '{"_next": "gate_approval"}'
|
||||
else
|
||||
jq -nc '{"_next": "implement"}'
|
||||
fi
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$(dirname "$0")/../../.shared/utils.sh"
|
||||
|
||||
if [[ -n "${GRAPH_STATE_FILE:-}" ]]; then
|
||||
state=$(cat "$GRAPH_STATE_FILE")
|
||||
elif [[ -n "${GRAPH_STATE:-}" ]]; then
|
||||
state="$GRAPH_STATE"
|
||||
else
|
||||
state='{}'
|
||||
fi
|
||||
|
||||
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
||||
|
||||
if [[ -n "${BUILD_CMD:-}" ]]; then
|
||||
cmd="$BUILD_CMD"
|
||||
else
|
||||
project_info=$(detect_project "$project_dir")
|
||||
cmd=$(echo "$project_info" | jq -r '.check // .build // ""')
|
||||
fi
|
||||
|
||||
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
||||
jq -nc '{
|
||||
"build_ok": true,
|
||||
"build_output": "(no build/check command available for this project type)",
|
||||
"_next": "verify_tests"
|
||||
}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit_code=0
|
||||
output=$(cd "$project_dir" && eval "$cmd" 2>&1) || exit_code=$?
|
||||
|
||||
if (( exit_code == 0 )); then
|
||||
jq -nc \
|
||||
--arg out "$output" \
|
||||
--arg cmd "$cmd" \
|
||||
'{
|
||||
"build_ok": true,
|
||||
"build_output": ("Ran: " + $cmd + "\n\n" + $out),
|
||||
"_next": "verify_tests"
|
||||
}'
|
||||
else
|
||||
jq -nc \
|
||||
--arg out "$output" \
|
||||
--arg cmd "$cmd" \
|
||||
--argjson rc "$exit_code" \
|
||||
'{
|
||||
"build_ok": false,
|
||||
"build_output": ("Ran: " + $cmd + "\nExit code: " + ($rc | tostring) + "\n\n" + $out),
|
||||
"_next": "fix_loop_gate"
|
||||
}'
|
||||
fi
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$(dirname "$0")/../../.shared/utils.sh"
|
||||
|
||||
if [[ -n "${GRAPH_STATE_FILE:-}" ]]; then
|
||||
state=$(cat "$GRAPH_STATE_FILE")
|
||||
elif [[ -n "${GRAPH_STATE:-}" ]]; then
|
||||
state="$GRAPH_STATE"
|
||||
else
|
||||
state='{}'
|
||||
fi
|
||||
|
||||
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
||||
|
||||
if [[ -n "${TEST_CMD:-}" ]]; then
|
||||
cmd="$TEST_CMD"
|
||||
else
|
||||
project_info=$(detect_project "$project_dir")
|
||||
cmd=$(echo "$project_info" | jq -r '.test // ""')
|
||||
fi
|
||||
|
||||
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
||||
jq -nc '{
|
||||
"tests_ok": true,
|
||||
"tests_output": "(no test command available for this project type)",
|
||||
"_next": "end_success"
|
||||
}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit_code=0
|
||||
output=$(cd "$project_dir" && eval "$cmd" 2>&1) || exit_code=$?
|
||||
|
||||
if (( exit_code == 0 )); then
|
||||
jq -nc \
|
||||
--arg out "$output" \
|
||||
--arg cmd "$cmd" \
|
||||
'{
|
||||
"tests_ok": true,
|
||||
"tests_output": ("Ran: " + $cmd + "\n\n" + $out),
|
||||
"_next": "end_success"
|
||||
}'
|
||||
else
|
||||
jq -nc \
|
||||
--arg out "$output" \
|
||||
--arg cmd "$cmd" \
|
||||
--argjson rc "$exit_code" \
|
||||
'{
|
||||
"tests_ok": false,
|
||||
"tests_output": ("Ran: " + $cmd + "\nExit code: " + ($rc | tostring) + "\n\n" + $out),
|
||||
"_next": "fix_loop_gate"
|
||||
}'
|
||||
fi
|
||||
@@ -14,99 +14,6 @@ _project_dir() {
|
||||
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
|
||||
}
|
||||
|
||||
# Normalize a path to be relative to project root.
|
||||
# Strips the project_dir prefix if the LLM passes an absolute path.
|
||||
# Usage: local rel_path; rel_path=$(_normalize_path "/abs/or/rel/path")
|
||||
_normalize_path() {
|
||||
local input_path="$1"
|
||||
local project_dir
|
||||
project_dir=$(_project_dir)
|
||||
|
||||
if [[ "${input_path}" == /* ]]; then
|
||||
input_path="${input_path#"${project_dir}"/}"
|
||||
fi
|
||||
|
||||
input_path="${input_path#./}"
|
||||
echo "${input_path}"
|
||||
}
|
||||
|
||||
# @cmd Read a file's contents before modifying
|
||||
# @option --path! Path to the file (relative to project root)
|
||||
read_file() {
|
||||
local file_path
|
||||
# shellcheck disable=SC2154
|
||||
file_path=$(_normalize_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
|
||||
file_path=$(_normalize_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}")"
|
||||
printf '%s' "${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
|
||||
file_path=$(_normalize_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 | sed "s|^${project_dir}/||" | 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 | sed "s|^${project_dir}/||" | 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
|
||||
@@ -189,28 +96,3 @@ get_project_structure() {
|
||||
} >> "$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/' | \
|
||||
sed "s|^${project_dir}/||" | \
|
||||
head -20) || true
|
||||
|
||||
if [[ -n "${results}" ]]; then
|
||||
echo "${results}" >> "$LLM_OUTPUT"
|
||||
else
|
||||
warn "No matches" >> "$LLM_OUTPUT"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
# deep-research
|
||||
|
||||
A deep web research agent, built as a Coyote graph agent. It plans an
|
||||
investigation, decomposes it into sub-questions researched in
|
||||
parallel, grounds the work in a local knowledge corpus, vets the
|
||||
credibility of cited sources, runs a reflexion self-critique loop to
|
||||
revise weak findings, delegates the final write-up to a focused
|
||||
sub-agent, checks that the cited sources are reachable, and gates the
|
||||
result behind human approval.
|
||||
|
||||
Unlike a regular agent (which takes a goal and improvises the steps),
|
||||
this agent runs a fixed graph: every request goes through the same
|
||||
`plan -> parallel research -> vet -> critique -> synthesize -> verify -> approve`
|
||||
pipeline.
|
||||
|
||||
This agent is also the **canonical reference for the Coyote graph
|
||||
system**: it exercises every node type (`script`, `llm`, `rag`, `map`,
|
||||
`agent`, `input`, `approval`, `end`) and both static fan-out and
|
||||
dynamic `map` fan-out. If you are learning how to build a graph
|
||||
agent, this is the file to read alongside the
|
||||
[Graph-Agents wiki](https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents).
|
||||
|
||||
## Workflow
|
||||
|
||||
17 nodes. `->` is the static route; a script node can also route
|
||||
dynamically via `_next`. The `▶▶` line is a parallel super-step —
|
||||
those branches run concurrently:
|
||||
|
||||
```
|
||||
parse_request (script) -> bootstrap_research (or -> ask_topic if no topic)
|
||||
ask_topic (input) -> bootstrap_research
|
||||
bootstrap_research (script) -> [plan, knowledge_lookup] ▶▶ parallel
|
||||
plan (llm + output_schema) -> research_each_question
|
||||
knowledge_lookup (rag) -> research_each_question
|
||||
research_each_question (map) -> combine_findings (spawns one branch per question)
|
||||
└─ research_one_question (llm) (atomic; runs N×, joins at map)
|
||||
combine_findings (script) -> vet_sources
|
||||
vet_sources (llm + custom tool) -> critique
|
||||
critique (llm) -> reflexion_gate
|
||||
reflexion_gate (script) -> synthesize (or -> research_each_question: reflexion loop)
|
||||
synthesize (agent: report-writer) -> verify_sources
|
||||
verify_sources (script) -> approve
|
||||
approve (approval) -> end_accepted ("accept")
|
||||
-> end_rejected ("reject")
|
||||
-> incorporate_feedback (any free-form answer)
|
||||
incorporate_feedback (script) -> research_each_question (the human-feedback loop)
|
||||
```
|
||||
|
||||
### Node-type breakdown
|
||||
|
||||
| Type | Nodes |
|
||||
|-----------------------------|-----------------------------------------------------------------------------------------------------------------------|
|
||||
| `script` (Python) | `parse_request`, `bootstrap_research`, `combine_findings`, `reflexion_gate`, `verify_sources`, `incorporate_feedback` |
|
||||
| `llm` (tools: `[]`) | `plan`, `critique` |
|
||||
| `llm` (with tool whitelist) | `research_one_question`, `vet_sources` |
|
||||
| `rag` | `knowledge_lookup` — local corpus retrieval |
|
||||
| `map` | `research_each_question` — dynamic fan-out per sub-question |
|
||||
| `agent` | `synthesize` — spawns the `report-writer` sub-agent |
|
||||
| `input` | `ask_topic` |
|
||||
| `approval` | `approve` |
|
||||
| `end` | `end_accepted`, `end_rejected` |
|
||||
|
||||
## Parallel execution
|
||||
|
||||
The graph has two parallel super-steps where Coyote's BSP scheduler runs
|
||||
branches concurrently.
|
||||
|
||||
**1. Context loading (`plan` ‖ `knowledge_lookup`)** — after
|
||||
`bootstrap_research`, the LLM planner (which decomposes the topic into
|
||||
sub-questions) and the RAG retrieval over the local `knowledge/`
|
||||
corpus run side by side. They write disjoint state keys (`plan` writes
|
||||
`research_plan` and `questions`; `knowledge_lookup` writes
|
||||
`local_context` and `local_sources`) so no reducer is needed.
|
||||
|
||||
**2. Per-question research (`research_each_question` map)** — the
|
||||
plan emits a `questions` array (3-5 entries, enforced by its
|
||||
`output_schema`). The `map` node spawns one parallel branch per
|
||||
question (`max_concurrency: 3`). Each branch is an isolated
|
||||
`research_one_question` LLM invocation with web tools, instructed to
|
||||
investigate exactly its assigned question. Outputs collect into
|
||||
`question_findings` in input order, then `combine_findings` joins
|
||||
them into a single `findings` Markdown document for downstream nodes.
|
||||
|
||||
`settings.max_concurrency: 4` is the graph-wide cap; the per-`map`
|
||||
override (`max_concurrency: 3` on `research_each_question`) is
|
||||
deliberately lower to leave headroom for the planner's tool calls
|
||||
running alongside RAG.
|
||||
|
||||
## Local knowledge corpus
|
||||
|
||||
`knowledge_lookup` is a `rag` node — it runs hybrid (vector + keyword)
|
||||
retrieval over every file in `knowledge/`. The directory ships with a
|
||||
small `research-style-notes.md` so the RAG node has something to
|
||||
retrieve against on a clean install; drop your own Markdown notes,
|
||||
PDFs, or text files into `knowledge/` to bias the research toward
|
||||
your local context.
|
||||
|
||||
The knowledge base is built once, at agent-load time, into
|
||||
`~/.config/coyote/agents/deep-research/knowledge_lookup.yaml`. Because
|
||||
the node fully specifies its build config (`embedding_model`,
|
||||
`chunk_size`, `chunk_overlap`), the build is non-interactive. Delete
|
||||
that cached file after adding or changing knowledge to force a
|
||||
rebuild.
|
||||
|
||||
## Sub-agent: report-writer
|
||||
|
||||
The `synthesize` node is an `agent` node that spawns the
|
||||
`report-writer` sub-agent (`assets/agents/report-writer/`). This is
|
||||
the agent-as-tool pattern: the orchestrating graph delegates the
|
||||
writing phase to a focused sub-agent dedicated to coherent prose,
|
||||
while the research phase uses different (typically cheaper) LLM nodes
|
||||
for fast-and-many-question investigation.
|
||||
|
||||
The `report-writer` sub-agent has no tools — it cannot access the
|
||||
web, cannot search, and cannot invent facts. It reads only the
|
||||
findings it is given and produces a final Markdown report preserving
|
||||
every inline citation. See `assets/agents/report-writer/README.md`
|
||||
for details.
|
||||
|
||||
## Tools and tool scoping
|
||||
|
||||
This agent demonstrates Coyote's three tool sources and how an `llm`
|
||||
node's `tools:` whitelist scopes them per node.
|
||||
|
||||
The agent's full tool universe, declared in `graph.yaml`:
|
||||
|
||||
- **Global tools** (`global_tools`): `web_search_coyote`,
|
||||
`fetch_url_via_curl`, `search_arxiv` - Coyote's built-in tool scripts.
|
||||
- **MCP server** (`mcp_servers`): `ddg-search` - a DuckDuckGo web
|
||||
search MCP server. Referenced in a whitelist as `mcp:ddg-search`.
|
||||
- **Custom agent tool** (`tools.sh`): `classify_source` - a
|
||||
deterministic source-credibility classifier shipped with this agent.
|
||||
|
||||
No node receives all of these. Each `llm` node's `tools:` whitelist
|
||||
narrows the universe to exactly what that step needs:
|
||||
|
||||
| Node | `tools:` whitelist | Draws from |
|
||||
|-------------------------|-----------------------------------------------------------------------------|--------------------------|
|
||||
| `plan`, `critique` | `[]` | nothing - pure reasoning |
|
||||
| `research_one_question` | `web_search_coyote`, `fetch_url_via_curl`, `search_arxiv`, `mcp:ddg-search` | global tools + MCP |
|
||||
| `vet_sources` | `classify_source` | the custom tool only |
|
||||
|
||||
`research_one_question` (each parallel branch of the map) can search
|
||||
and fetch but cannot classify sources; `vet_sources` can classify
|
||||
sources but cannot touch the web. That separation is the point of the
|
||||
`tools:` whitelist: a node gets only the tools its job calls for,
|
||||
never the agent's full set.
|
||||
|
||||
The `classify_source` custom tool (`tools.sh`) takes a URL and returns
|
||||
a credibility tier (government, academic, preprint, organization,
|
||||
unverified) derived from the host and top-level domain. It is
|
||||
deterministic - exactly the kind of logic a tool should own rather than
|
||||
the LLM guessing.
|
||||
|
||||
Web search may require API-key configuration; see the
|
||||
[Tools](https://github.com/Dark-Alex-17/coyote/wiki/Tools) docs.
|
||||
`fetch_url_via_curl`, `search_arxiv`, and `classify_source` work
|
||||
without a key.
|
||||
|
||||
## Setup
|
||||
|
||||
`research_one_question` (each parallel branch of the `map`) uses the
|
||||
`ddg-search` MCP server via `mcp:ddg-search`. It is one of Coyote's
|
||||
default MCP servers; make sure it is registered in
|
||||
`~/.config/coyote/mcp.json` (run `coyote --install mcp_config` to restore
|
||||
the default template if it is missing). If `ddg-search` is unavailable,
|
||||
the branches still have their global web-search tools to fall back on.
|
||||
|
||||
The `synthesize` node spawns the `report-writer` sub-agent. Both
|
||||
agents ship with `coyote agents install`; if you install one manually,
|
||||
install both so the agent reference resolves.
|
||||
|
||||
## Reflexion
|
||||
|
||||
The agent has two loops, both built with script nodes that route via
|
||||
`_next`. The engine allows back-edges at runtime; the validator only
|
||||
rejects cycles built from static `next` / `routes` edges, so script
|
||||
`_next` loops are always allowed.
|
||||
|
||||
**Automated reflexion loop.** After the parallel research map and
|
||||
`vet_sources`, the `critique` node reviews the merged findings
|
||||
against the research plan and the source credibility assessment, and
|
||||
emits `VERDICT: PASS` or `VERDICT: REVISE` with specific feedback.
|
||||
`reflexion_gate.py` then:
|
||||
|
||||
- `PASS` -> continue to `synthesize`.
|
||||
- `REVISE`, budget remaining -> loop back to `research_each_question`,
|
||||
with the critique injected as `research_feedback` so every parallel
|
||||
branch sees it on the retry.
|
||||
- `REVISE`, budget spent -> continue to `synthesize` anyway (the human
|
||||
approval step is the final backstop).
|
||||
|
||||
The budget is `MAX_REFLEXION_REVISIONS` in `reflexion_gate.py`
|
||||
(default 2, so the research map runs at most 3 times per pass).
|
||||
|
||||
**Human-feedback loop.** At `approve` the user answers `accept`,
|
||||
`reject`, or types their own feedback. A free-form answer routes via
|
||||
the approval node's `on_other` to `incorporate_feedback.py`, which
|
||||
folds that text into `research_feedback` and loops back to
|
||||
`research_each_question` for another parallel pass.
|
||||
|
||||
`settings.max_loop_iterations` (40) is the engine's infinite-loop
|
||||
backstop: it caps the total visits to any single node.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
coyote agents install # ships deep-research
|
||||
coyote -a deep-research "How does HTTP/3 differ from HTTP/2?"
|
||||
coyote -a deep-research "Recent advances in solid-state batteries"
|
||||
coyote -a deep-research # no prompt -> triggers ask_topic
|
||||
```
|
||||
|
||||
## Anti-hallucination
|
||||
|
||||
- `research_one_question` (each map branch) is instructed to back
|
||||
every claim with a real retrieved source and never to fabricate
|
||||
URLs, titles, or DOIs.
|
||||
- `vet_sources` classifies every cited source so weak sources are
|
||||
visible to the critique step.
|
||||
- `critique` independently reviews the merged findings and sends weak
|
||||
or uncited work back for another parallel research pass.
|
||||
- `synthesize` (the `report-writer` sub-agent) is grounded: it may use
|
||||
only the gathered findings and must keep each claim's inline source.
|
||||
It has no tools and cannot browse the web.
|
||||
- `verify_sources` probes every cited URL / DOI with an HTTP HEAD
|
||||
request and reports which are unreachable, so the human reviewer
|
||||
sees broken citations before approving.
|
||||
|
||||
## Customizing
|
||||
|
||||
- **Loop budget.** `MAX_REFLEXION_REVISIONS` in `reflexion_gate.py`.
|
||||
- **Map concurrency.** The `research_each_question` node's
|
||||
`max_concurrency: 3` caps simultaneous web-research branches.
|
||||
Raise to investigate more questions in parallel; lower to be gentle
|
||||
on rate-limited providers.
|
||||
- **Per-node model.** Add `model: anthropic:...` to any `llm` node.
|
||||
Cheap models work well for `plan` / `critique` / `vet_sources`; the
|
||||
heavy intelligence is needed in `research_one_question` and the
|
||||
`report-writer` sub-agent.
|
||||
- **Tool scope.** Narrow the `research_one_question` node's `tools:`
|
||||
list to constrain where each branch looks (for example, drop
|
||||
`web_search_coyote` and `mcp:ddg-search` to force arXiv-only
|
||||
research).
|
||||
- **Local knowledge.** Drop files into `knowledge/` to bias every
|
||||
research branch toward your local context (see the *Local
|
||||
knowledge corpus* section above).
|
||||
- **Different writer.** Replace `agent: report-writer` on the
|
||||
`synthesize` node with the name of any other agent. The
|
||||
orchestrator does not care what kind of agent the writer is.
|
||||
- **Skip approval.** Point both `approve` routes at `end_accepted`,
|
||||
or wire `verify_sources` straight to an `end` node.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
assets/agents/deep-research/
|
||||
graph.yaml - agent config + 17-node workflow
|
||||
tools.sh - classify_source custom tool
|
||||
README.md - this file
|
||||
knowledge/
|
||||
README.md - corpus-format notes
|
||||
research-style-notes.md - starter knowledge file (replace with your notes)
|
||||
scripts/
|
||||
parse_request.py - _next: bootstrap_research, or ask_topic if no topic
|
||||
bootstrap_research.py - fan-out source: next [plan, knowledge_lookup]
|
||||
combine_findings.py - joins map output (question_findings) into findings
|
||||
reflexion_gate.py - _next: research_each_question (revise) or synthesize
|
||||
verify_sources.py - HTTP HEAD on cited URLs / DOIs
|
||||
incorporate_feedback.py - _next: research_each_question, with user feedback
|
||||
```
|
||||
|
||||
See also `assets/agents/report-writer/` — the sub-agent the
|
||||
`synthesize` node spawns.
|
||||
@@ -0,0 +1,293 @@
|
||||
name: deep-research
|
||||
description: |
|
||||
Deep web research workflow. Plans an investigation, decomposes it
|
||||
into sub-questions researched in parallel, grounds the work in a
|
||||
local knowledge corpus, vets the credibility of cited sources, runs
|
||||
a reflexion self-critique loop to revise weak or incomplete findings,
|
||||
delegates the final write-up to a focused sub-agent, checks that the
|
||||
cited sources are reachable, and gates the result behind human
|
||||
approval. A reviewer's free-form feedback at the approval step feeds
|
||||
back into another research pass.
|
||||
|
||||
This is the canonical Coyote graph-agent reference: it exercises every
|
||||
node type (script, llm, rag, map, agent, input, approval, end) and
|
||||
both static fan-out and dynamic map fan-out.
|
||||
|
||||
version: "1.0"
|
||||
|
||||
temperature: 0.0
|
||||
|
||||
global_tools:
|
||||
- web_search_coyote.sh
|
||||
- fetch_url_via_curl.sh
|
||||
- search_arxiv.sh
|
||||
|
||||
mcp_servers:
|
||||
- ddg-search
|
||||
|
||||
conversation_starters:
|
||||
- "How does HTTP/3 differ from HTTP/2?"
|
||||
- "Summarize recent advances in solid-state battery chemistry"
|
||||
|
||||
settings:
|
||||
max_loop_iterations: 40
|
||||
log_state_snapshots: false
|
||||
validate_before_run: true
|
||||
max_concurrency: 4
|
||||
|
||||
initial_state:
|
||||
research_feedback: ""
|
||||
research_attempts: 0
|
||||
local_context: ""
|
||||
local_sources: ""
|
||||
|
||||
start: parse_request
|
||||
|
||||
nodes:
|
||||
|
||||
parse_request:
|
||||
id: parse_request
|
||||
type: script
|
||||
script: scripts/parse_request.py
|
||||
next: bootstrap_research
|
||||
|
||||
ask_topic:
|
||||
id: ask_topic
|
||||
type: input
|
||||
question: "What would you like me to research?"
|
||||
validation: "len(input) > 0"
|
||||
state_updates:
|
||||
topic: "{{input}}"
|
||||
next: bootstrap_research
|
||||
|
||||
bootstrap_research:
|
||||
id: bootstrap_research
|
||||
type: script
|
||||
script: scripts/bootstrap_research.py
|
||||
next: [plan, knowledge_lookup]
|
||||
|
||||
plan:
|
||||
id: plan
|
||||
type: llm
|
||||
instructions: |
|
||||
You are a research planner. Given a topic, produce a focused
|
||||
research plan and decompose it into 3-5 specific sub-questions
|
||||
that can each be researched independently in parallel.
|
||||
|
||||
The plan is a short narrative naming the key questions and the
|
||||
kinds of sources that would be authoritative. The sub-questions
|
||||
are precise, self-contained queries (each one is sent on its own
|
||||
to a separate research worker, so they must be answerable
|
||||
without each other's context).
|
||||
prompt: "Research topic: {{topic}}"
|
||||
tools: []
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
research_plan:
|
||||
type: string
|
||||
description: A short plan narrative.
|
||||
questions:
|
||||
type: array
|
||||
items: { type: string }
|
||||
minItems: 1
|
||||
maxItems: 6
|
||||
description: 3-5 specific, self-contained sub-questions.
|
||||
required: [research_plan, questions]
|
||||
next: research_each_question
|
||||
|
||||
knowledge_lookup:
|
||||
id: knowledge_lookup
|
||||
type: rag
|
||||
documents:
|
||||
- ./knowledge/
|
||||
query: "{{topic}}"
|
||||
top_k: 6
|
||||
chunk_size: 1000
|
||||
chunk_overlap: 100
|
||||
state_updates:
|
||||
local_context: "{{output.context}}"
|
||||
local_sources: "{{output.sources}}"
|
||||
next: research_each_question
|
||||
|
||||
research_each_question:
|
||||
id: research_each_question
|
||||
type: map
|
||||
over: "{{questions}}"
|
||||
as: question
|
||||
branch: research_one_question
|
||||
collect_into: question_findings
|
||||
max_concurrency: 3
|
||||
next: combine_findings
|
||||
|
||||
research_one_question:
|
||||
id: research_one_question
|
||||
type: llm
|
||||
instructions: |
|
||||
You are a web research assistant. Investigate the SINGLE question
|
||||
given to you using your tools: search the web, fetch and read
|
||||
pages, and search arXiv for academic sources.
|
||||
|
||||
Rules:
|
||||
- Every factual claim must be backed by a real source you
|
||||
actually retrieved. Never fabricate URLs, page titles,
|
||||
authors, or DOIs.
|
||||
- Prefer primary and authoritative sources over aggregators.
|
||||
- Where sources disagree, report the disagreement rather than
|
||||
papering over it.
|
||||
- Put the URL (or DOI) inline next to each claim it supports.
|
||||
|
||||
Return organized findings in plain text. Do not include
|
||||
meta-commentary about the process.
|
||||
prompt: |
|
||||
Research question: {{question}}
|
||||
|
||||
Local context that may help:
|
||||
{{local_context}}
|
||||
|
||||
{{research_feedback}}
|
||||
tools:
|
||||
- web_search_coyote
|
||||
- fetch_url_via_curl
|
||||
- search_arxiv
|
||||
- mcp:ddg-search
|
||||
max_iterations: 10
|
||||
max_attempts: 2
|
||||
temperature: 0.1
|
||||
|
||||
combine_findings:
|
||||
id: combine_findings
|
||||
type: script
|
||||
script: scripts/combine_findings.py
|
||||
next: vet_sources
|
||||
|
||||
vet_sources:
|
||||
id: vet_sources
|
||||
type: llm
|
||||
instructions: |
|
||||
You assess the credibility of the sources cited in a set of
|
||||
research findings. For every distinct source URL in the findings,
|
||||
call the `classify_source` tool to get its credibility tier. Then
|
||||
summarize: which claims rest on HIGH-credibility sources, and
|
||||
which rest on PREPRINT or UNVERIFIED sources and so need
|
||||
corroboration. Do NOT do any new research -- assess only what is
|
||||
already cited.
|
||||
prompt: |
|
||||
Findings to assess:
|
||||
{{findings}}
|
||||
tools:
|
||||
- classify_source
|
||||
max_iterations: 15
|
||||
state_updates:
|
||||
source_assessment: "{{output}}"
|
||||
next: critique
|
||||
|
||||
critique:
|
||||
id: critique
|
||||
type: llm
|
||||
instructions: |
|
||||
You are a meticulous research reviewer. Judge whether the
|
||||
findings below are good enough to synthesize a complete,
|
||||
well-supported report that answers the research plan.
|
||||
|
||||
Mark the findings REVISE if ANY of these hold:
|
||||
- A research-plan question is unanswered or only weakly
|
||||
addressed.
|
||||
- A factual claim has no source, or cites a source that looks
|
||||
fabricated.
|
||||
- The findings lean on a single source where corroboration is
|
||||
needed.
|
||||
- A key claim rests only on a PREPRINT or UNVERIFIED source,
|
||||
per the source credibility assessment below.
|
||||
- An obvious counter-perspective or recent development is
|
||||
missing.
|
||||
Otherwise mark them PASS.
|
||||
|
||||
Respond in EXACTLY this format, nothing else:
|
||||
|
||||
VERDICT: <PASS or REVISE>
|
||||
FEEDBACK: <if REVISE, be specific and actionable -- name the gaps
|
||||
and what kind of source would close them; if PASS, write "none">
|
||||
prompt: |
|
||||
Research plan:
|
||||
{{research_plan}}
|
||||
|
||||
Findings under review:
|
||||
{{findings}}
|
||||
|
||||
Source credibility assessment:
|
||||
{{source_assessment}}
|
||||
tools: []
|
||||
state_updates:
|
||||
critique: "{{output}}"
|
||||
next: reflexion_gate
|
||||
|
||||
reflexion_gate:
|
||||
id: reflexion_gate
|
||||
type: script
|
||||
script: scripts/reflexion_gate.py
|
||||
next: synthesize
|
||||
|
||||
synthesize:
|
||||
id: synthesize
|
||||
type: agent
|
||||
agent: report-writer
|
||||
prompt: |
|
||||
Research topic: {{topic}}
|
||||
|
||||
Findings (organized by sub-question, with inline citations):
|
||||
{{findings}}
|
||||
|
||||
Source credibility assessment:
|
||||
{{source_assessment}}
|
||||
|
||||
Produce the final report following your instructions.
|
||||
timeout: 300
|
||||
state_updates:
|
||||
report: "{{output}}"
|
||||
next: verify_sources
|
||||
|
||||
verify_sources:
|
||||
id: verify_sources
|
||||
type: script
|
||||
script: scripts/verify_sources.py
|
||||
next: approve
|
||||
|
||||
approve:
|
||||
id: approve
|
||||
type: approval
|
||||
question: |
|
||||
Research report on: {{topic}}
|
||||
|
||||
{{report}}
|
||||
|
||||
----
|
||||
{{source_check}}
|
||||
----
|
||||
|
||||
Accept this report? Pick "accept" or "reject", or type specific
|
||||
feedback to send the research back for another pass.
|
||||
options:
|
||||
- "accept"
|
||||
- "reject"
|
||||
routes:
|
||||
"accept": end_accepted
|
||||
"reject": end_rejected
|
||||
on_other: incorporate_feedback
|
||||
state_updates:
|
||||
decision: "{{choice}}"
|
||||
|
||||
incorporate_feedback:
|
||||
id: incorporate_feedback
|
||||
type: script
|
||||
script: scripts/incorporate_feedback.py
|
||||
|
||||
end_accepted:
|
||||
id: end_accepted
|
||||
type: end
|
||||
output: "{{report}}"
|
||||
|
||||
end_rejected:
|
||||
id: end_rejected
|
||||
type: end
|
||||
output: "Research on '{{topic}}' was rejected and discarded."
|
||||
@@ -0,0 +1,23 @@
|
||||
# Local knowledge corpus for deep-research
|
||||
|
||||
The `knowledge_lookup` node in `graph.yaml` is a `rag` node that runs
|
||||
hybrid (vector + keyword) retrieval over every file in this directory.
|
||||
Drop your own notes, papers (PDFs), Markdown docs, or text files here
|
||||
and they will be indexed into a per-agent knowledge base on first run.
|
||||
|
||||
Coyote supports common file types out of the box: `.md`, `.txt`, `.pdf`,
|
||||
`.html`, and others. Subdirectories are walked recursively.
|
||||
|
||||
A small starter file (`research-style-notes.md`) ships so the RAG
|
||||
node has something non-empty to retrieve against on a clean install.
|
||||
Replace or extend it with your own materials to bias the research
|
||||
phase toward your local context.
|
||||
|
||||
To force the knowledge base to rebuild after you add or change files,
|
||||
delete the cached index:
|
||||
|
||||
```sh
|
||||
rm ~/.config/coyote/agents/deep-research/knowledge_lookup.yaml
|
||||
```
|
||||
|
||||
The next run will rebuild from the current contents of this directory.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Research style notes
|
||||
|
||||
These are general principles the `deep-research` agent should keep in
|
||||
mind regardless of topic. Replace this file with your own notes if you
|
||||
want to bias retrieval toward your local context.
|
||||
|
||||
## What "good research" means here
|
||||
|
||||
- **Every factual claim cites a source you actually retrieved.** Never
|
||||
fabricate URLs, page titles, authors, or DOIs.
|
||||
- **Primary sources beat aggregators.** Prefer the original paper, the
|
||||
RFC, the standards body, or the manufacturer over a blog summarizing
|
||||
them.
|
||||
- **Corroboration matters where stakes are high.** If a single source
|
||||
makes a strong claim, look for a second independent source before
|
||||
taking it as established.
|
||||
- **Disagreement is information, not noise.** If two credible sources
|
||||
disagree, report the disagreement and the reasoning on each side.
|
||||
- **Old does not mean wrong.** A 2014 RFC is still authoritative if no
|
||||
newer one has obsoleted it; check before assuming a source is stale.
|
||||
|
||||
## Source-tier heuristics
|
||||
|
||||
The `vet_sources` node uses these rough tiers to weigh credibility.
|
||||
The custom tool `classify_source` (see `tools.sh`) implements this
|
||||
deterministically by hostname / TLD.
|
||||
|
||||
- **HIGH:** government domains (`.gov`, `.mil`), academic institutions
|
||||
(`.edu`, university subdomains), peer-reviewed journals, standards
|
||||
bodies (IETF/RFCs, W3C, ISO, IEEE, NIST), and primary documents from
|
||||
the entities being researched (e.g. a vendor's official spec page).
|
||||
- **PREPRINT:** arXiv, bioRxiv, medRxiv, SSRN. Useful but not yet
|
||||
peer-reviewed; treat numeric claims with extra caution.
|
||||
- **ORGANIZATION:** established nonprofits, standards-adjacent groups,
|
||||
industry consortia. Reliable for their stated mission but may have a
|
||||
perspective.
|
||||
- **UNVERIFIED:** general web pages, blogs, news aggregators, social
|
||||
media. Useful for leads but should not be the only source for a
|
||||
factual claim.
|
||||
|
||||
## Common pitfalls to flag in critique
|
||||
|
||||
- A claim cited only to a PREPRINT or UNVERIFIED source on a numeric
|
||||
or contested point.
|
||||
- A research-plan question that the findings address only obliquely.
|
||||
- "Findings" that paraphrase a single source three times rather than
|
||||
triangulating.
|
||||
- Citation collisions where two sources are listed but turn out to
|
||||
be the same study reported via different aggregators.
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fan-out source for context loading.
|
||||
|
||||
Has no logic of its own. Exists so the static `next: [plan, knowledge_lookup]`
|
||||
list on this node fans out into two parallel branches (the LLM planner and
|
||||
the RAG knowledge lookup) as a single super-step. The validator requires
|
||||
declared parallel-branch script outputs, so we emit an empty JSON object
|
||||
explicitly here.
|
||||
"""
|
||||
import json
|
||||
|
||||
|
||||
def main():
|
||||
print(json.dumps({}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Join the per-question map outputs into a single `findings` string.
|
||||
|
||||
The `research_each_question` map writes `question_findings` (an array,
|
||||
one entry per sub-question, in input order). Downstream nodes
|
||||
(`vet_sources`, `critique`, `synthesize`) read `{{findings}}` as a
|
||||
single block, so this script renders the array as a Markdown document
|
||||
with one section per question.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def load_state():
|
||||
path = os.environ.get("GRAPH_STATE_FILE")
|
||||
if path:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
|
||||
|
||||
|
||||
def main():
|
||||
state = load_state()
|
||||
questions = state.get("questions") or []
|
||||
per_question = state.get("question_findings") or []
|
||||
|
||||
sections = []
|
||||
for idx, q in enumerate(questions):
|
||||
body = per_question[idx] if idx < len(per_question) else ""
|
||||
if isinstance(body, dict) or isinstance(body, list):
|
||||
body = json.dumps(body, indent=2)
|
||||
sections.append(f"## {q}\n\n{body}")
|
||||
|
||||
findings = "\n\n".join(sections) if sections else "No findings gathered."
|
||||
print(json.dumps({"findings": findings}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fold a reviewer's free-form feedback back into the research loop.
|
||||
|
||||
Runs when the user answers the approval step with their own text
|
||||
instead of "accept" or "reject". That text (saved by the approval node
|
||||
as `decision`) becomes `research_feedback`, and the graph loops back to
|
||||
`research_each_question` for another informed pass (each sub-question is
|
||||
re-researched in parallel with the new feedback in context). The
|
||||
reflexion counter is reset so the user-driven pass gets a fresh revision
|
||||
budget.
|
||||
|
||||
Routing (`_next`): always research_each_question.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def load_state():
|
||||
path = os.environ.get("GRAPH_STATE_FILE")
|
||||
if path:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
|
||||
|
||||
|
||||
def main():
|
||||
state = load_state()
|
||||
feedback = (state.get("decision") or "").strip()
|
||||
output = {
|
||||
"_next": "research_each_question",
|
||||
"research_attempts": 0,
|
||||
"research_feedback": (
|
||||
"The user reviewed the report and asked for changes. Treat "
|
||||
"this as the top priority for the next pass:\n\n" + feedback
|
||||
),
|
||||
}
|
||||
print(json.dumps(output))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Entry router for deep-research.
|
||||
|
||||
Reads the caller's prompt from state. If it contains a usable research
|
||||
topic, stores it as `topic` and falls through to the static `next`
|
||||
(plan). If the prompt is empty, routes to `ask_topic` so the user can
|
||||
supply one interactively.
|
||||
|
||||
Routing (`_next`):
|
||||
- prompt present -> (no _next; static next: plan)
|
||||
- prompt empty -> ask_topic
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def load_state():
|
||||
path = os.environ.get("GRAPH_STATE_FILE")
|
||||
if path:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
|
||||
|
||||
|
||||
def main():
|
||||
state = load_state()
|
||||
prompt = (state.get("initial_prompt") or "").strip()
|
||||
if prompt:
|
||||
print(json.dumps({"topic": prompt}))
|
||||
else:
|
||||
print(json.dumps({"_next": "ask_topic"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reflexion gate for deep-research.
|
||||
|
||||
Runs after `critique` has reviewed the current research findings. If the
|
||||
critique's verdict is REVISE and the reflexion budget is not spent,
|
||||
loops back to `research` with the critique attached as
|
||||
`research_feedback`, so the retry is informed rather than a blind
|
||||
re-run. Otherwise it proceeds to `synthesize`.
|
||||
|
||||
Routing (`_next`):
|
||||
- verdict PASS -> synthesize
|
||||
- verdict REVISE, budget remaining -> research_each_question (+ research_feedback)
|
||||
- verdict REVISE, budget spent -> synthesize
|
||||
|
||||
Reflexion is a best-effort quality booster, not a hard gate: once the
|
||||
budget is spent the workflow proceeds anyway, and the human approval
|
||||
step is the final backstop.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
# Automated revision passes allowed. `research` runs at most
|
||||
# MAX_REFLEXION_REVISIONS + 1 times per user pass. Bump to allow more.
|
||||
MAX_REFLEXION_REVISIONS = 2
|
||||
|
||||
|
||||
def load_state():
|
||||
path = os.environ.get("GRAPH_STATE_FILE")
|
||||
if path:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
|
||||
|
||||
|
||||
def as_int(value, default=0):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def parse_verdict(critique):
|
||||
"""Pull PASS/REVISE from the critique's `VERDICT:` line. Defaults to
|
||||
PASS when no verdict line is found, so a malformed critique lets the
|
||||
workflow proceed instead of burning the whole revision budget."""
|
||||
match = re.search(r"VERDICT:\s*([A-Za-z]+)", critique, re.IGNORECASE)
|
||||
if not match:
|
||||
return "PASS"
|
||||
return match.group(1).upper()
|
||||
|
||||
|
||||
def main():
|
||||
state = load_state()
|
||||
critique = state.get("critique") or ""
|
||||
verdict = parse_verdict(critique)
|
||||
attempts = as_int(state.get("research_attempts"))
|
||||
|
||||
if verdict == "REVISE" and attempts < MAX_REFLEXION_REVISIONS:
|
||||
feedback = (
|
||||
"A reviewer judged the previous research pass incomplete. "
|
||||
"Address every point in the critique below:\n\n" + critique
|
||||
)
|
||||
output = {
|
||||
"_next": "research_each_question",
|
||||
"research_attempts": attempts + 1,
|
||||
"research_feedback": feedback,
|
||||
}
|
||||
else:
|
||||
output = {"_next": "synthesize"}
|
||||
|
||||
print(json.dumps(output))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that the sources cited in the research report are reachable.
|
||||
|
||||
Scans the final report for URLs and DOIs, probes each with a HEAD
|
||||
request, and writes a `source_check` summary into state so the human
|
||||
reviewer sees broken citations at the approval step.
|
||||
|
||||
Times out per request so a slow source cannot stall the graph.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
DOI_RE = re.compile(r"\b(10\.\d{4,9}/[-._;()/:A-Z0-9]+)", re.IGNORECASE)
|
||||
URL_RE = re.compile(r"https?://[^\s)\]\}\"'>]+")
|
||||
|
||||
|
||||
def load_state():
|
||||
path = os.environ.get("GRAPH_STATE_FILE")
|
||||
if path:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
|
||||
|
||||
|
||||
def reachable(url, timeout=5.0):
|
||||
req = urllib.request.Request(url, method="HEAD")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return 200 <= resp.status < 400
|
||||
except urllib.error.HTTPError as e:
|
||||
return 200 <= e.code < 400
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
state = load_state()
|
||||
report = state.get("report") or ""
|
||||
|
||||
urls = sorted({u.rstrip(".,;)") for u in URL_RE.findall(report)})
|
||||
dois = sorted(set(DOI_RE.findall(report)))
|
||||
|
||||
results = []
|
||||
for url in urls:
|
||||
ok = reachable(url)
|
||||
results.append(f" {'OK' if ok else 'UNREACHABLE'} {url}")
|
||||
for doi in dois:
|
||||
url = f"https://doi.org/{doi}"
|
||||
if url in urls:
|
||||
continue
|
||||
ok = reachable(url)
|
||||
results.append(f" {'OK' if ok else 'UNREACHABLE'} DOI {doi} ({url})")
|
||||
|
||||
if not results:
|
||||
summary = "No web sources were cited in the report."
|
||||
else:
|
||||
summary = (
|
||||
f"Source reachability ({len(results)} checked):\n"
|
||||
+ "\n".join(results)
|
||||
)
|
||||
|
||||
print(json.dumps({"source_check": summary}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# @env LLM_OUTPUT=/dev/stdout The output path
|
||||
|
||||
# @cmd Classify the credibility tier of a web source from its URL.
|
||||
# A deterministic check based on the host and top-level domain. Use it
|
||||
# to weigh how much trust to place in a source before relying on it.
|
||||
# @option --url! The full source URL to classify
|
||||
classify_source() {
|
||||
# shellcheck disable=SC2154
|
||||
local url="$argc_url"
|
||||
local host="${url#*://}"
|
||||
host="${host%%/*}"
|
||||
host="${host##*@}"
|
||||
host="${host%%:*}"
|
||||
host="$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
local tier
|
||||
case "$host" in
|
||||
'')
|
||||
tier="UNKNOWN - no host could be parsed from the URL" ;;
|
||||
*.gov | *.gov.* | *.mil)
|
||||
tier="HIGH - government source" ;;
|
||||
*.edu | *.edu.* | *.ac.*)
|
||||
tier="HIGH - academic institution" ;;
|
||||
arxiv.org | *.arxiv.org | biorxiv.org | *.biorxiv.org | medrxiv.org | *.medrxiv.org | ssrn.com | *.ssrn.com)
|
||||
tier="PREPRINT - not yet peer reviewed, corroborate before citing" ;;
|
||||
wikipedia.org | *.wikipedia.org)
|
||||
tier="TERTIARY - encyclopedia, good for orientation not citation" ;;
|
||||
*.org | *.org.*)
|
||||
tier="MEDIUM - organization site, check for institutional bias" ;;
|
||||
*)
|
||||
tier="UNVERIFIED - general web source, corroborate before citing" ;;
|
||||
esac
|
||||
|
||||
printf '%s: %s\n' "${host:-<none>}" "$tier" >> "$LLM_OUTPUT"
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
This agent serves as a demo to guide agent development and showcase various agent capabilities.
|
||||
|
||||
To enable tools, Loki will look for the first `tools.py` or `tools.sh` file it finds in this directory.
|
||||
To enable tools, Coyote will look for the first `tools.py` or `tools.sh` file it finds in this directory.
|
||||
|
||||
The base configuration using `tools.py`. To switch to using `tools.sh`, rename or remove `tools.py`.
|
||||
|
||||
@@ -17,7 +17,7 @@ It can also be used as a standalone tool for understanding codebases and finding
|
||||
## Pro-Tip: Use an IDE MCP Server for Improved Performance
|
||||
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
|
||||
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
|
||||
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure
|
||||
server to your config (see the [MCP Server docs](https://github.com/Dark-Alex-17/loki/wiki/MCP-Servers) to see how to configure
|
||||
them), and modify the agent definition to look like this:
|
||||
|
||||
```yaml
|
||||
@@ -31,7 +31,7 @@ global_tools:
|
||||
- fs_grep.sh
|
||||
- fs_glob.sh
|
||||
- fs_ls.sh
|
||||
- web_search_loki.sh
|
||||
- web_search_coyote.sh
|
||||
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Jira AI Agent
|
||||
|
||||
## 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.
|
||||
|
||||
## 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`).
|
||||
|
||||
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!
|
||||
@@ -1,37 +0,0 @@
|
||||
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.
|
||||
|
||||
## 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__}}
|
||||
conversation_starters:
|
||||
- What are the latest issues in my Jira project?
|
||||
- Can you create a new Jira issue for me?
|
||||
- What are my open Jira issues?
|
||||
- Can you search for issues with the label "bug" in my Jira project?
|
||||
@@ -19,7 +19,7 @@ It can also be used as a standalone tool for design reviews and solving difficul
|
||||
## Pro-Tip: Use an IDE MCP Server for Improved Performance
|
||||
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
|
||||
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
|
||||
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure
|
||||
server to your config (see the [MCP Server docs](https://github.com/Dark-Alex-17/loki/wiki/MCP-Servers) to see how to configure
|
||||
them), and modify the agent definition to look like this:
|
||||
|
||||
```yaml
|
||||
@@ -33,7 +33,7 @@ global_tools:
|
||||
- fs_grep.sh
|
||||
- fs_glob.sh
|
||||
- fs_ls.sh
|
||||
- web_search_loki.sh
|
||||
- web_search_coyote.sh
|
||||
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# report-writer
|
||||
|
||||
A tiny, focused sub-agent that turns a set of research findings into a
|
||||
single coherent final report. Reads only what it is given — does not
|
||||
do independent research, does not access the web, does not invent
|
||||
facts. It exists as a focused tool for orchestrating agents to
|
||||
delegate the writing phase to.
|
||||
|
||||
## Why a separate agent?
|
||||
|
||||
This is an example of the **agent-as-tool** pattern in graph agents.
|
||||
The `deep-research` graph agent's `synthesize` node is an `agent` node
|
||||
that spawns this one (see `assets/agents/deep-research/graph.yaml`).
|
||||
Separating the role has two practical benefits:
|
||||
|
||||
- The orchestrating agent can use a cheap model (or a high-temperature
|
||||
exploratory one) for the research phase, while letting the writing
|
||||
phase use a different (typically lower-temperature, possibly larger)
|
||||
model dedicated to coherent prose.
|
||||
- The writing prompt is owned by this agent's `config.yaml` rather
|
||||
than buried inside another agent's graph. You can polish it
|
||||
independently without touching the research flow.
|
||||
|
||||
## Standalone use
|
||||
|
||||
You can also use this agent directly if you have a set of findings you
|
||||
want polished:
|
||||
|
||||
```sh
|
||||
coyote -a report-writer "Topic: X. Findings: <paste findings here>"
|
||||
```
|
||||
|
||||
It will produce a single Markdown report following the rules in its
|
||||
system prompt: executive summary at the top, grouped sections by
|
||||
related sub-questions, every inline citation preserved verbatim, and a
|
||||
final "Open questions / disagreements" section.
|
||||
|
||||
## What it will NOT do
|
||||
|
||||
- Search the web, fetch URLs, query an MCP server, or use any tool.
|
||||
It has no tools configured.
|
||||
- Invent facts beyond what is in the findings you give it.
|
||||
- Strip or rewrite citations.
|
||||
|
||||
These constraints are the point of the agent existing: a writer that
|
||||
the orchestrator can trust to stay in its lane.
|
||||
@@ -0,0 +1,34 @@
|
||||
name: report-writer
|
||||
description: Polishes research findings into a clear, citation-preserving final report
|
||||
version: 1.0.0
|
||||
temperature: 0.2
|
||||
|
||||
instructions: |
|
||||
You are a technical writer. You will be given:
|
||||
- a research topic
|
||||
- a set of findings, organized per sub-question, with inline
|
||||
citations next to each claim
|
||||
- a source-credibility assessment of the cited sources
|
||||
|
||||
Your job is to produce a single, well-organized final report:
|
||||
|
||||
Rules:
|
||||
- Use ONLY the findings provided. Do not introduce facts from
|
||||
your own memory. Do not speculate beyond what the findings
|
||||
support.
|
||||
- Preserve every inline citation. If a sentence in the findings
|
||||
had a URL or DOI, the equivalent sentence in your report must
|
||||
keep the same citation.
|
||||
- Lead with a 2-3 sentence executive summary at the top.
|
||||
- Organize the body so that related sub-questions are grouped,
|
||||
not strictly one section per question. The findings are raw
|
||||
material; the report should read as a single coherent answer
|
||||
to the original topic.
|
||||
- End with a short "Open questions / disagreements" section
|
||||
naming anything the findings flagged as unresolved or
|
||||
contested.
|
||||
|
||||
Output plain Markdown. No metadata, no JSON wrapper.
|
||||
|
||||
conversation_starters:
|
||||
- "Polish these findings into a cited report"
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sisyphus
|
||||
|
||||
The main coordinator agent for the Loki coding ecosystem, providing a powerful CLI interface for code generation and
|
||||
The main coordinator agent for the Coyote 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._
|
||||
@@ -18,23 +18,22 @@ Sisyphus acts as the primary entry point, capable of handling complex tasks by c
|
||||
- 🛠️ **Tool Integration**: Seamlessly uses system tools for building, testing, and file manipulation.
|
||||
|
||||
## Pro-Tip: Use an IDE MCP Server for Improved Performance
|
||||
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
|
||||
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
|
||||
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure
|
||||
them), and modify the agent definition to look like this:
|
||||
Many modern IDEs (JetBrains, VS Code, Cursor, Zed, etc.) expose MCP servers that let LLMs use IDE tools directly. Using
|
||||
one dramatically improves the performance of coding agents. If you have one, add it to your coyote config (see the
|
||||
[MCP Server docs](https://github.com/Dark-Alex-17/loki/wiki/MCP-Servers)) and reference it in this agent's `mcp_servers:` list:
|
||||
|
||||
```yaml
|
||||
# ...
|
||||
|
||||
mcp_servers:
|
||||
- jetbrains
|
||||
- your-ide-mcp-server
|
||||
|
||||
global_tools:
|
||||
- fs_read.sh
|
||||
- fs_grep.sh
|
||||
- fs_glob.sh
|
||||
- fs_ls.sh
|
||||
- web_search_loki.sh
|
||||
- web_search_coyote.sh
|
||||
- execute_command.sh
|
||||
|
||||
# ...
|
||||
|
||||
@@ -119,20 +119,21 @@ instructions: |
|
||||
1. todo__init --goal "Add user profiles API endpoint"
|
||||
2. todo__add --task "Explore existing API patterns"
|
||||
3. todo__add --task "Implement profile endpoint"
|
||||
4. todo__add --task "Verify with build/test"
|
||||
5. agent__spawn --agent explore --prompt "Find existing API endpoint patterns, route structures, and controller conventions. Include code snippets."
|
||||
6. agent__spawn --agent explore --prompt "Find existing data models and database query patterns. Include code snippets."
|
||||
7. agent__collect --id <id1>
|
||||
8. agent__collect --id <id2>
|
||||
9. todo__done --id 1
|
||||
10. agent__spawn --agent coder --prompt "<structured prompt using Coder Delegation Format above, including code snippets from explore results>"
|
||||
11. agent__collect --id <coder_id>
|
||||
12. todo__done --id 2
|
||||
13. run_build
|
||||
14. run_tests
|
||||
15. todo__done --id 3
|
||||
4. agent__spawn --agent explore --prompt "Find existing API endpoint patterns, route structures, and controller conventions. Include code snippets."
|
||||
5. agent__spawn --agent explore --prompt "Find existing data models and database query patterns. Include code snippets."
|
||||
6. agent__collect --id <id1>
|
||||
7. agent__collect --id <id2>
|
||||
8. todo__done --id 1
|
||||
9. agent__spawn --agent coder --prompt "<structured prompt using Coder Delegation Format above, including code snippets from explore results>"
|
||||
10. agent__collect --id <coder_id>
|
||||
11. todo__done --id 2
|
||||
```
|
||||
|
||||
Note: the `coder` agent is a graph agent that runs verification (build +
|
||||
tests) and a bounded fix-loop internally. You do NOT need to spawn a
|
||||
separate build/test step. A `CODER_COMPLETE` outcome means build and
|
||||
tests already passed.
|
||||
|
||||
### Example 2: Architecture/design question (explore + oracle in parallel)
|
||||
|
||||
User: "How should I structure the authentication for this app?"
|
||||
@@ -172,6 +173,22 @@ instructions: |
|
||||
10. **Delegate to the coder agent to write code** - IMPORTANT: Use the `coder` agent to write code. Do not try to write code yourself except for trivial changes
|
||||
11. **Always output a summary of changes when finished** - Make it clear to user's that you've completed your tasks
|
||||
|
||||
## Coder Outcomes
|
||||
|
||||
The `coder` agent is a graph agent that runs the implement -> verify_build
|
||||
-> verify_tests -> fix_loop pipeline internally. It always returns one of
|
||||
three sentinel outcomes:
|
||||
|
||||
- `CODER_COMPLETE` - implementation succeeded with build + tests green.
|
||||
Continue with any follow-up todos.
|
||||
- `CODER_REJECTED` - user rejected the plan at the approval gate (only
|
||||
triggered for high-complexity plans). Do NOT re-spawn coder blindly;
|
||||
ask the user what to change first.
|
||||
- `CODER_FAILED` - the fix-loop exhausted its budget without producing
|
||||
green build/tests. The failure output includes the last build and tests
|
||||
output. Surface this to the user; consider spawning `oracle` for
|
||||
diagnosis if the failure is unclear.
|
||||
|
||||
## When to Do It Yourself
|
||||
|
||||
- Simple command execution
|
||||
|
||||
-1106
File diff suppressed because it is too large
Load Diff
@@ -73,11 +73,11 @@ def to_args:
|
||||
to_entries | .[] |
|
||||
(.key | split("_") | join("-")) as $key |
|
||||
if .value | type == "array" then
|
||||
.value | .[] | "--\($key) \(. | escape_shell_word)"
|
||||
.value | .[] | "--\($key)=\(. | escape_shell_word)"
|
||||
elif .value | type == "boolean" then
|
||||
if .value then "--\($key)" else "" end
|
||||
else
|
||||
"--\($key) \(.value | escape_shell_word)"
|
||||
"--\($key)=\(.value | escape_shell_word)"
|
||||
end;
|
||||
[ to_args ] | join(" ")
|
||||
EOF
|
||||
|
||||
@@ -70,11 +70,11 @@ def to_args:
|
||||
to_entries | .[] |
|
||||
(.key | split("_") | join("-")) as $key |
|
||||
if .value | type == "array" then
|
||||
.value | .[] | "--\($key) \(. | escape_shell_word)"
|
||||
.value | .[] | "--\($key)=\(. | escape_shell_word)"
|
||||
elif .value | type == "boolean" then
|
||||
if .value then "--\($key)" else "" end
|
||||
else
|
||||
"--\($key) \(.value | escape_shell_word)"
|
||||
"--\($key)=\(.value | escape_shell_word)"
|
||||
end;
|
||||
[ to_args ] | join(" ")
|
||||
EOF
|
||||
|
||||
+6
-6
@@ -6,11 +6,11 @@ set -e
|
||||
|
||||
# @option --query! The search query.
|
||||
|
||||
# @meta require-tools loki
|
||||
# @meta require-tools coyote
|
||||
|
||||
# @env WEB_SEARCH_MODEL=gemini:gemini-2.5-flash The model for web-searching.
|
||||
#
|
||||
# supported loki models:
|
||||
# supported coyote models:
|
||||
# - gemini:gemini-2.0-*
|
||||
# - vertexai:gemini-*
|
||||
# - perplexity:*
|
||||
@@ -22,15 +22,15 @@ main() {
|
||||
client="${WEB_SEARCH_MODEL%%:*}"
|
||||
|
||||
if [[ "$client" == "gemini" ]]; then
|
||||
export LOKI_PATCH_GEMINI_CHAT_COMPLETIONS='{".*":{"body":{"tools":[{"google_search":{}}]}}}'
|
||||
export COYOTE_PATCH_GEMINI_CHAT_COMPLETIONS='{".*":{"body":{"tools":[{"google_search":{}}]}}}'
|
||||
elif [[ "$client" == "vertexai" ]]; then
|
||||
export LOKI_PATCH_VERTEXAI_CHAT_COMPLETIONS='{
|
||||
export COYOTE_PATCH_VERTEXAI_CHAT_COMPLETIONS='{
|
||||
"gemini-1.5-.*":{"body":{"tools":[{"googleSearchRetrieval":{}}]}},
|
||||
"gemini-2.0-.*":{"body":{"tools":[{"google_search":{}}]}}
|
||||
}'
|
||||
elif [[ "$client" == "ernie" ]]; then
|
||||
export LOKI_PATCH_ERNIE_CHAT_COMPLETIONS='{".*":{"body":{"web_search":{"enable":true}}}}'
|
||||
export COYOTE_PATCH_ERNIE_CHAT_COMPLETIONS='{".*":{"body":{"web_search":{"enable":true}}}}'
|
||||
fi
|
||||
|
||||
loki -m "$WEB_SEARCH_MODEL" "$argc_query" >> "$LLM_OUTPUT"
|
||||
coyote -m "$WEB_SEARCH_MODEL" "$argc_query" >> "$LLM_OUTPUT"
|
||||
}
|
||||
@@ -506,16 +506,14 @@ open_link() {
|
||||
}
|
||||
|
||||
guard_operation() {
|
||||
if [[ -t 1 ]]; then
|
||||
if [[ -z "$AUTO_CONFIRM" && -z "$LLM_AGENT_VAR_AUTO_CONFIRM" ]]; 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
|
||||
}
|
||||
|
||||
# Here is an example of a patch block that can be applied to modify the file to request the user's name:
|
||||
@@ -655,19 +653,17 @@ guard_path() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
path="$(_to_real_path "$1")"
|
||||
confirmation_prompt="$2"
|
||||
path="$(_to_real_path "$1")"
|
||||
confirmation_prompt="$2"
|
||||
|
||||
if [[ ! "$path" == "$(pwd)"* && -z "$AUTO_CONFIRM" && -z "$LLM_AGENT_VAR_AUTO_CONFIRM" ]]; 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
|
||||
fi
|
||||
fi
|
||||
if [[ "$ans" == 0 ]]; then
|
||||
error "Operation aborted!" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
_to_real_path() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
---
|
||||
enabled_mcp_servers: atlassian
|
||||
---
|
||||
You are the librarian for the company's Confluence and Jira knowledge bases. Your job is to help users find and retrieve
|
||||
information from these platforms. Use all tools at your disposal to answer user queries.
|
||||
|
||||
Available Tools:
|
||||
{{__tools__}}
|
||||
@@ -9,7 +9,7 @@ security/configuration settings. The analysis aims to ensure a thorough understa
|
||||
structured and operates, enabling the creation of new files, maintaining consistency with existing practices, and the
|
||||
potential implementation of best practices.
|
||||
|
||||
Should the root directory contain a `LOKI.md` file, this was generated by Loki and should be used as a reference
|
||||
Should the root directory contain a `COYOTE.md` file, this was generated by Coyote and should be used as a reference
|
||||
point for all analysis, style questions, etc.
|
||||
|
||||
**Objective:** Enable the AI to thoroughly analyze a software repository, providing detailed insights and guidelines on
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Agent-specific configuration
|
||||
# Location `<loki-config-dir>/agents/<agent-name>/config.yaml`
|
||||
# Location `<coyote-config-dir>/agents/<agent-name>/config.yaml`
|
||||
#
|
||||
# Available Environment Variables:
|
||||
# - <agent-name>_MODEL
|
||||
@@ -17,16 +17,18 @@ 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 (Todo System)
|
||||
# The auto-continue system provides built-in task tracking for improved reliability.
|
||||
# When enabled, the model can create todo lists and the system will automatically
|
||||
# prompt it to continue when incomplete tasks remain.
|
||||
# See the [Todo System documentation](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System) for more information
|
||||
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)
|
||||
# Sub-Agent Spawning System
|
||||
# Enable this agent to spawn and manage child agents in parallel.
|
||||
# See docs/AGENTS.md for detailed documentation.
|
||||
# See https://github.com/Dark-Alex-17/coyote/wiki/Agents for detailed documentation.
|
||||
can_spawn_agents: false # Enable the agent to spawn child agents
|
||||
max_concurrent_agents: 4 # Maximum number of agents that can run simultaneously
|
||||
max_agent_depth: 3 # Maximum nesting depth for sub-agents (prevents runaway spawning)
|
||||
@@ -35,7 +37,7 @@ summarization_model: null # Model to use for summarizing sub-agent output
|
||||
summarization_threshold: 4000 # Character threshold above which sub-agent output is summarized before returning to parent
|
||||
escalation_timeout: 300 # Seconds a sub-agent waits for a user interaction response before timing out (default: 5 minutes)
|
||||
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
|
||||
- github # Corresponds to the name of an MCP server in the `<coyote-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
|
||||
- web_search
|
||||
- fs
|
||||
@@ -78,10 +80,10 @@ conversation_starters: # Optional conversation starters for the agent
|
||||
- What is the best way to exercise?
|
||||
- How do I manage my time effectively?
|
||||
documents: # Optional documents to load for the agent
|
||||
- git:/some/repo # Explicitly tell Loki to use the 'git' document loader using an absolute path
|
||||
- pdf:some-pdf-file.pdf # Explicitly tell Loki to use the 'pdf' document loader using a relative path
|
||||
- git:/some/repo # Explicitly tell Coyote to use the 'git' document loader using an absolute path
|
||||
- pdf:some-pdf-file.pdf # Explicitly tell Coyote to use the 'pdf' document loader using a relative path
|
||||
- https://some-website.com/some-page
|
||||
- some-file.pdf # File with relative path to the <loki-config-dir>/agents/<agent-name> directory; i.e. file in the same directory as this config file
|
||||
- some-file.pdf # File with relative path to the <coyote-config-dir>/agents/<agent-name> directory; i.e. file in the same directory as this config file
|
||||
- ~/some-file.txt # File in the user's home directory
|
||||
- /absolute/path/to/some-file.md # File with absolute path
|
||||
- /absolute/path/**/NAME.txt # Find all NAME.txt files in the specified directory and all its subdirectories
|
||||
|
||||
+55
-46
@@ -18,31 +18,31 @@ agent_session: null # Set a session to use when starting an agent (
|
||||
|
||||
# ---- Appearance ----
|
||||
highlight: true # Controls syntax highlighting
|
||||
light_theme: false # Activates a light color theme when true. env: LOKI_LIGHT_THEME
|
||||
light_theme: false # Activates a light color theme when true. env: COYOTE_LIGHT_THEME
|
||||
|
||||
# ---- Miscellaneous ----
|
||||
user_agent: null # Set User-Agent HTTP header, use `auto` for loki/<current-version>
|
||||
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
|
||||
save_shell_history: true # Whether to save shell execution command to the history file
|
||||
sync_models_url: > # URL to sync model changes from
|
||||
https://raw.githubusercontent.com/Dark-Alex-17/loki/refs/heads/main/models.yaml
|
||||
https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
|
||||
|
||||
# ---- REPL Prompt ----
|
||||
# Custom REPL left/right prompts; see the [REPL Prompt Documentation](./docs/REPL-PROMPT.md) for more information
|
||||
# Custom REPL left/right prompts; see the [REPL Prompt Documentation](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt) for more information
|
||||
left_prompt:
|
||||
'{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} '
|
||||
right_prompt:
|
||||
'{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}'
|
||||
|
||||
# ---- Vault ----
|
||||
# See the [Vault documentation](./docs/VAULT.md) for more information on the Loki vault
|
||||
vault_password_file: null # Path to a file containing the password for the Loki vault (cannot be a secret template)
|
||||
# See the [Vault documentation](https://github.com/Dark-Alex-17/coyote/wiki/Vault) for more information on the Coyote vault
|
||||
vault_password_file: null # Path to a file containing the password for the Coyote vault (cannot be a secret template)
|
||||
|
||||
# ---- Function Calling ----
|
||||
# See the [Tools documentation](./docs/function-calling/TOOLS.md) for more details
|
||||
# See the [Tools documentation](https://github.com/Dark-Alex-17/coyote/wiki/Tools) 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_read,fs_glob,fs_grep'
|
||||
enabled_tools: null # Which tools to enable by default. (e.g. 'fs,web_search_loki')
|
||||
enabled_tools: null # Which tools to enable by default. (e.g. 'fs,web_search_coyote')
|
||||
visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools')
|
||||
# - demo_py.py
|
||||
# - demo_sh.sh
|
||||
@@ -64,25 +64,34 @@ visible_tools: # Which tools are visible to be compiled (and a
|
||||
# - get_current_weather.py
|
||||
# - get_current_weather.ts
|
||||
- get_current_weather.sh
|
||||
- query_jira_issues.sh
|
||||
# - search_arxiv.sh
|
||||
# - search_wikipedia.sh
|
||||
# - search_wolframalpha.sh
|
||||
# - send_mail.sh
|
||||
# - send_twilio.sh
|
||||
# - web_search_loki.sh
|
||||
# - web_search_coyote.sh
|
||||
# - web_search_perplexity.sh
|
||||
# - web_search_tavily.sh
|
||||
|
||||
# ---- MCP Servers ----
|
||||
# See the [MCP Servers documentation](./docs/MCP-SERVERS.md) for more details
|
||||
# See the [MCP Servers documentation](https://github.com/Dark-Alex-17/coyote/wiki/MCP-Servers) for more details
|
||||
mcp_server_support: true # Enables or disables MCP servers (globally).
|
||||
mapping_mcp_servers: # Alias for an MCP server or set of servers
|
||||
git: github,gitmcp
|
||||
enabled_mcp_servers: null # Which MCP servers to enable by default (e.g. 'github,slack,ddg-search')
|
||||
|
||||
# ---- Auto-Continue (Todo System) ----
|
||||
# The auto-continue system provides built-in task tracking for improved reliability.
|
||||
# When enabled, the model can create todo lists and the system will automatically
|
||||
# prompt it to continue when incomplete tasks remain.
|
||||
# See the [Todo System documentation](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System) for more information
|
||||
auto_continue: false # Enable automatic continuation when incomplete todos remain (default: false)
|
||||
max_auto_continues: 10 # Maximum number of automatic continuations before stopping (default: 10)
|
||||
inject_todo_instructions: true # Inject default todo usage instructions into the system prompt (default: true)
|
||||
continuation_prompt: null # Custom prompt used when auto-continuing. If null, uses built-in default
|
||||
|
||||
# ---- Session ----
|
||||
# See the [Session documentation](./docs/SESSIONS.md) for more information
|
||||
# See the [Session documentation](https://github.com/Dark-Alex-17/coyote/wiki/Sessions) for more information
|
||||
save_session: null # Controls the persistence of the session. If true, auto save; if false, don't auto-save save; if null, ask the user what to do
|
||||
compression_threshold: 4000 # Compress the session when the token count reaches or exceeds this threshold
|
||||
summarization_prompt: > # The text prompt used for creating a concise summary of session message
|
||||
@@ -91,9 +100,9 @@ summary_context_prompt: > # The text prompt used for including the summar
|
||||
'This is a summary of the chat history as a recap: '
|
||||
|
||||
# ---- RAG ----
|
||||
# See the [RAG Docs](./docs/RAG.md) for more details.
|
||||
# See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details.
|
||||
rag_embedding_model: null # Specifies the embedding model used for context retrieval
|
||||
rag_reranker_model: null # Specifies the reranker model used for sorting retrieved documents; Loki uses Reciprocal Rank Fusion by default
|
||||
rag_reranker_model: null # Specifies the reranker model used for sorting retrieved documents; Coyote uses Reciprocal Rank Fusion by default
|
||||
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
|
||||
@@ -132,12 +141,12 @@ document_loaders:
|
||||
docx: 'pandoc --to plain $1' # Use pandoc to convert a .docx file to text
|
||||
# (see https://pandoc.org for details on how to install pandoc)
|
||||
jina: 'curl -fsSL https://r.jina.ai/$1 -H "Authorization: Bearer {{JINA_API_KEY}}' # Use Jina to translate a website into text;
|
||||
# Requires a Jina API key to be added to the Loki vault
|
||||
# Requires a Jina API key to be added to the Coyote vault
|
||||
git: > # Use yek to load a git repository into the knowledgebase (https://github.com/bodo-run/yek)
|
||||
sh -c "yek $1 --json | jq 'map({ path: .filename, contents: .content })'"
|
||||
|
||||
# ---- Clients ----
|
||||
# See the [Clients documentation](./docs/clients/CLIENTS.md) for more details
|
||||
# See the [Clients documentation](https://github.com/Dark-Alex-17/coyote/wiki/Clients) for more details
|
||||
clients:
|
||||
# All clients have the following configuration:
|
||||
# - type: xxxx
|
||||
@@ -168,14 +177,14 @@ clients:
|
||||
# See https://platform.openai.com/docs/quickstart
|
||||
- type: openai
|
||||
api_base: https://api.openai.com/v1 # Optional
|
||||
api_key: '{{OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
organization_id: org-xxx # Optional
|
||||
|
||||
# For any platform compatible with OpenAI's API
|
||||
- type: openai-compatible
|
||||
name: ollama
|
||||
api_base: http://localhost:11434/v1
|
||||
api_key: '{{OLLAMA_API_KEY}}' # Optional; You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{OLLAMA_API_KEY}}' # Optional; You can either hard-code or inject secrets from the Coyote vault
|
||||
models:
|
||||
- name: deepseek-r1
|
||||
max_input_tokens: 131072
|
||||
@@ -193,9 +202,9 @@ clients:
|
||||
# See https://ai.google.dev/docs
|
||||
- type: gemini
|
||||
api_base: https://generativelanguage.googleapis.com/v1beta
|
||||
api_key: '{{GEMINI_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
auth: null # When set to 'oauth', Loki will use OAuth instead of an API key
|
||||
# Authenticate with `loki --authenticate` or `.authenticate` in the REPL
|
||||
api_key: '{{GEMINI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key
|
||||
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
|
||||
patch:
|
||||
chat_completions:
|
||||
'.*':
|
||||
@@ -213,49 +222,49 @@ clients:
|
||||
# See https://docs.anthropic.com/claude/reference/getting-started-with-the-api
|
||||
- type: claude
|
||||
api_base: https://api.anthropic.com/v1 # Optional
|
||||
api_key: '{{ANTHROPIC_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
auth: null # When set to 'oauth', Loki will use OAuth instead of an API key
|
||||
# Authenticate with `loki --authenticate` or `.authenticate` in the REPL
|
||||
api_key: '{{ANTHROPIC_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key
|
||||
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
|
||||
|
||||
# See https://docs.mistral.ai/
|
||||
- type: openai-compatible
|
||||
name: mistral
|
||||
api_base: https://api.mistral.ai/v1
|
||||
api_key: '{{MISTRAL_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{MISTRAL_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://docs.x.ai/docs
|
||||
- type: openai-compatible
|
||||
name: xai
|
||||
api_base: https://api.x.ai/v1
|
||||
api_key: '{{XAI_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{XAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://docs.ai21.com/docs/overview
|
||||
- type: openai-compatible
|
||||
name: ai12
|
||||
api_base: https://api.ai21.com/studio/v1
|
||||
api_key: '{{AI21_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{AI21_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://docs.cohere.com/docs/the-cohere-platform
|
||||
- type: cohere
|
||||
api_base: https://api.cohere.ai/v2 # Optional
|
||||
api_key: '{{COHERE_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{COHERE_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://docs.perplexity.ai/getting-started/overview
|
||||
- type: openai-compatible
|
||||
name: perplexity
|
||||
api_base: https://api.perplexity.ai
|
||||
api_key: '{{PERPLEXITY_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{PERPLEXITY_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://console.groq.com/docs/quickstart
|
||||
- type: openai-compatible
|
||||
name: groq
|
||||
api_base: https://api.groq.com/openai/v1
|
||||
api_key: '{{GROQ_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{GROQ_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://learn.microsoft.com/en-us/azure/ai-services/openai/chatgpt-quickstart
|
||||
- type: azure-openai
|
||||
api_base: https://{RESOURCE}.openai.azure.com
|
||||
api_key: '{{AZURE_OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{AZURE_OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
models:
|
||||
- name: gpt-4o # Model deployment name
|
||||
max_input_tokens: 128000
|
||||
@@ -286,8 +295,8 @@ clients:
|
||||
|
||||
# See https://docs.aws.amazon.com/bedrock/latest/userguide/
|
||||
- type: bedrock
|
||||
access_key_id: '{{AWS_ACCESS_KEY_ID}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
secret_access_key: '{{AWS_SECRET_ACCESS_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
access_key_id: '{{AWS_ACCESS_KEY_ID}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
secret_access_key: '{{AWS_SECRET_ACCESS_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
region: xxx
|
||||
session_token: xxx # Optional, only needed for temporary credentials
|
||||
|
||||
@@ -295,67 +304,67 @@ clients:
|
||||
- type: openai-compatible
|
||||
name: cloudflare
|
||||
api_base: https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1
|
||||
api_key: '{{CLOUDFLARE_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{CLOUDFLARE_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html
|
||||
- type: openai-compatible
|
||||
name: ernie
|
||||
api_base: https://qianfan.baidubce.com/v2
|
||||
api_key: '{{BAIDU_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{BAIDU_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://dashscope.aliyun.com/
|
||||
- type: openai-compatible
|
||||
name: qianwen
|
||||
api_base: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
api_key: '{{ALIYUN_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{ALIYUN_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://cloud.tencent.com/product/hunyuan
|
||||
- type: openai-compatible
|
||||
name: hunyuan
|
||||
api_base: https://api.hunyuan.cloud.tencent.com/v1
|
||||
api_key: '{{TENCENT_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{TENCENT_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://platform.moonshot.cn/docs/intro
|
||||
- type: openai-compatible
|
||||
name: moonshot
|
||||
api_base: https://api.moonshot.cn/v1
|
||||
api_key: '{{MOONSHOT_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{MOONSHOT_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://platform.deepseek.com/api-docs/
|
||||
- type: openai-compatible
|
||||
name: deepseek
|
||||
api_base: https://api.deepseek.com
|
||||
api_key: '{{DEEPSEEK_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{DEEPSEEK_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://open.bigmodel.cn/dev/howuse/introduction
|
||||
- type: openai-compatible
|
||||
name: zhipuai
|
||||
api_base: https://open.bigmodel.cn/api/paas/v4
|
||||
api_key: '{{ZHIPUAI_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{ZHIPUAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://platform.minimaxi.com/document/Fast%20access
|
||||
- type: openai-compatible
|
||||
name: minimax
|
||||
api_base: https://api.minimax.chat/v1
|
||||
api_key: '{{MINIMAX_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{MINIMAX_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://openrouter.ai/docs#quick-start
|
||||
- type: openai-compatible
|
||||
name: openrouter
|
||||
api_base: https://openrouter.ai/api/v1
|
||||
api_key: '{{OPENROUTER_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{OPENROUTER_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://github.com/marketplace/models
|
||||
- type: openai-compatible
|
||||
name: github
|
||||
api_base: https://models.inference.ai.azure.com
|
||||
api_key: '{{GITHUB_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{GITHUB_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://deepinfra.com/docs
|
||||
- type: openai-compatible
|
||||
name: deepinfra
|
||||
api_base: https://api.deepinfra.com/v1/openai
|
||||
api_key: '{{DEEPINFRA_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{DEEPINFRA_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
|
||||
# ----- RAG dedicated -----
|
||||
@@ -364,10 +373,10 @@ clients:
|
||||
- type: openai-compatible
|
||||
name: jina
|
||||
api_base: https://api.jina.ai/v1
|
||||
api_key: '{{JINA_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{JINA_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://docs.voyageai.com/docs/introduction
|
||||
- type: openai-compatible
|
||||
name: voyageai
|
||||
api_base: https://api.voyageai.com/v1
|
||||
api_key: '{{VOYAGEAI_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
|
||||
api_key: '{{VOYAGEAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
+14
-1
@@ -1,5 +1,9 @@
|
||||
---
|
||||
# Everything in this section is optional
|
||||
############################################
|
||||
## Everything in this section is optional ##
|
||||
############################################
|
||||
|
||||
# Role Configuration
|
||||
name: <role-name> # The name of the role
|
||||
model: openai:gpt-4o # The model to use for this role
|
||||
temperature: 0.2 # The temperature to use for this role when querying the model
|
||||
@@ -8,5 +12,14 @@ enabled_tools: fs_ls,fs_cat # A comma-separated list of tools to enabl
|
||||
enabled_mcp_servers: github,gitmcp # A comma-separated list of MCP servers to enable for this role
|
||||
prompt: null # A custom prompt to use for this role that will immediately query
|
||||
# the model for output instead of using the instructions below
|
||||
# Auto-Continue (Todo System)
|
||||
# The auto-continue system provides built-in task tracking for improved reliability.
|
||||
# When enabled, the model can create todo lists and the system will automatically
|
||||
# prompt it to continue when incomplete tasks remain.
|
||||
# See the [Todo System documentation](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System) for more information
|
||||
auto_continue: false # Enable automatic continuation when incomplete todos remain (default: false)
|
||||
max_auto_continues: 10 # Maximum number of automatic continuations before stopping (default: 10)
|
||||
inject_todo_instructions: true # Inject default todo tool usage instructions into the system prompt (default: true)
|
||||
continuation_prompt: null # Custom prompt used when auto-continuing. If null, uses built-in default
|
||||
---
|
||||
You are an expert at doing things. This is where you write the instructions for the role.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Documentation: https://docs.brew.sh/Formula-Cookbook
|
||||
# https://rubydoc.brew.sh/Formula
|
||||
class Coyote < Formula
|
||||
desc "All-in-one, batteries included LLM CLI tool"
|
||||
homepage "https://github.com/Dark-Alex-17/coyote"
|
||||
if OS.mac? and Hardware::CPU.arm?
|
||||
url "https://github.com/Dark-Alex-17/coyote/releases/download/v$version/coyote-aarch64-apple-darwin.tar.gz"
|
||||
sha256 "$hash_mac_arm"
|
||||
elsif OS.mac? and Hardware::CPU.intel?
|
||||
url "https://github.com/Dark-Alex-17/coyote/releases/download/v$version/coyote-x86_64-apple-darwin.tar.gz"
|
||||
sha256 "$hash_mac"
|
||||
else
|
||||
url "https://github.com/Dark-Alex-17/coyote/releases/download/v$version/coyote-x86_64-unknown-linux-musl.tar.gz"
|
||||
sha256 "$hash_linux"
|
||||
end
|
||||
version "$version"
|
||||
license "MIT"
|
||||
|
||||
def install
|
||||
bin.install "coyote"
|
||||
ohai "You're done! Get started with \"coyote --help\""
|
||||
end
|
||||
end
|
||||
@@ -1,23 +0,0 @@
|
||||
# Documentation: https://docs.brew.sh/Formula-Cookbook
|
||||
# https://rubydoc.brew.sh/Formula
|
||||
class Loki < Formula
|
||||
desc "All-in-one, batteries included LLM CLI tool"
|
||||
homepage "https://github.com/Dark-Alex-17/loki"
|
||||
if OS.mac? and Hardware::CPU.arm?
|
||||
url "https://github.com/Dark-Alex-17/loki/releases/download/v$version/loki-aarch64-apple-darwin.tar.gz"
|
||||
sha256 "$hash_mac_arm"
|
||||
elsif OS.mac? and Hardware::CPU.intel?
|
||||
url "https://github.com/Dark-Alex-17/loki/releases/download/v$version/loki-x86_64-apple-darwin.tar.gz"
|
||||
sha256 "$hash_mac"
|
||||
else
|
||||
url "https://github.com/Dark-Alex-17/loki/releases/download/v$version/loki-x86_64-unknown-linux-musl.tar.gz"
|
||||
sha256 "$hash_linux"
|
||||
end
|
||||
version "$version"
|
||||
license "MIT"
|
||||
|
||||
def install
|
||||
bin.install "loki"
|
||||
ohai "You're done! Get started with \"loki --help\""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,427 @@
|
||||
# Graph-based agent definition (full-featured reference)
|
||||
# Location: <coyote-config-dir>/agents/<agent-name>/graph.yaml
|
||||
#
|
||||
# A graph agent is defined by this file alone. An agent directory contains
|
||||
# either a config.yaml (a normal LLM-loop agent) or a graph.yaml (a graph
|
||||
# agent), never both. The presence of graph.yaml is what makes the agent
|
||||
# a graph agent.
|
||||
#
|
||||
# This file is a reference: it documents every available field, themed
|
||||
# around a deep web research workflow with parallel retrieval. It is not
|
||||
# a runnable agent as-is. The `agent:`, `script:`, and `documents:` values
|
||||
# point at things that would need to exist for a real agent. For a real,
|
||||
# runnable deep-research graph agent, see assets/agents/deep-research/.
|
||||
#
|
||||
# Full documentation:
|
||||
# https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity
|
||||
# ---------------------------------------------------------------------------
|
||||
name: deep-research-example # Agent name (should match the directory name)
|
||||
description: | # Free-form prose describing the workflow
|
||||
A reference workflow: triage a research request, retrieve local
|
||||
context, branch on a script decision, run either a sub-agent or an
|
||||
LLM research step, then gate the result behind human approval.
|
||||
version: "1.0" # Graph schema version. Only "1.0" is accepted.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-level config (all optional)
|
||||
# The same knobs a normal agent's config.yaml carries. In a graph agent they
|
||||
# live here instead of in a config.yaml.
|
||||
# ---------------------------------------------------------------------------
|
||||
model: claude:claude-sonnet-4-6 # Default model for `llm` nodes that don't override it
|
||||
temperature: 0.0 # Default sampling temperature for `llm` nodes
|
||||
top_p: null # Default sampling top-p for `llm` nodes
|
||||
|
||||
global_tools: # Tool universe an `llm` node's `tools:` whitelist draws from
|
||||
- web_search_coyote.sh
|
||||
- fetch_url_via_curl.sh
|
||||
|
||||
mcp_servers: # MCP servers an `llm` node may reference via `mcp:<server>`
|
||||
- ddg-search
|
||||
|
||||
conversation_starters: # Suggested prompts surfaced in the UI
|
||||
- "Research the current state of WebAssembly outside the browser"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent variables (optional)
|
||||
# Declared the same way as a normal agent's config.yaml `variables:` block.
|
||||
# Each variable becomes available to:
|
||||
# - LLM nodes via the template form `{{name}}` once seeded into state
|
||||
# (see initial_state below).
|
||||
# - Script nodes via the env var `LLM_AGENT_VAR_<UPPER_NAME>`.
|
||||
# Values may be overridden at runtime with
|
||||
# `coyote -a <agent> --agent-variable <name> <value> "..."`.
|
||||
# ---------------------------------------------------------------------------
|
||||
variables:
|
||||
- name: project_dir
|
||||
description: |
|
||||
Absolute path to the project directory.
|
||||
default: "."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution settings (all optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
settings:
|
||||
max_loop_iterations: 100 # Per-node visit cap. If one node id is entered more
|
||||
# than this many times, execution aborts. Default 100.
|
||||
timeout: 600 # Optional wall-clock cap (seconds) on the whole run,
|
||||
# checked between node transitions.
|
||||
log_state_snapshots: true # Log state before each node (debug/trace). Default true.
|
||||
validate_before_run: true # Run the graph validator at startup. Default true.
|
||||
max_concurrency: 4 # Cap on simultaneously running branches in any
|
||||
# super-step (static fan-out OR a `map` node).
|
||||
# Default 4. Per-`map` overrides this. See Parallel
|
||||
# Execution below.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reducers (optional, required whenever two parallel branches write the same
|
||||
# state key in the same super-step; otherwise the validator errors at load).
|
||||
#
|
||||
# A reducer says how two values for the same key get merged. Built-ins:
|
||||
# append list += [value] (single value appended to a list)
|
||||
# extend list += value (a list) (list-of-lists flattened by one level)
|
||||
# concat "a\nb" (string join with newline separator)
|
||||
# sum a + b (numeric add; ints stay ints)
|
||||
# max max(a, b)
|
||||
# min min(a, b)
|
||||
# merge {**a, **b} (dict union, RHS wins on key collision)
|
||||
# overwrite last-write-wins (explicit opt-in; B's value replaces A's)
|
||||
#
|
||||
# Keys not listed here have an implicit "single writer per super-step" rule:
|
||||
# the validator rejects any graph where two parallel branches both write a
|
||||
# key with no reducer.
|
||||
# ---------------------------------------------------------------------------
|
||||
reducers:
|
||||
sources: append # The diamond below writes `sources` from both
|
||||
# branches; append accumulates them into a list.
|
||||
context: concat # Each branch contributes prose; concat joins them.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed state (optional)
|
||||
# Values placed into graph state before any node runs; reference anywhere via
|
||||
# {{key}}.
|
||||
#
|
||||
# Note: `initial_prompt` is seeded automatically by Coyote with the
|
||||
# caller's prompt. So there's no need to set it here.
|
||||
# ---------------------------------------------------------------------------
|
||||
initial_state:
|
||||
audience: "general reader"
|
||||
# Seed an empty default for any key that a strict field (a node prompt /
|
||||
# instructions / question / End output) references but that is only set on
|
||||
# some paths. `refinement` is set only if the `refine` input node runs;
|
||||
# seeding it "" keeps `finalize`'s strict prompt from failing on the
|
||||
# approve-directly path.
|
||||
refinement: ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
start: triage # ID of the first node to run (must exist in `nodes`)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nodes
|
||||
# Each node is keyed by its id. The `id:` inside a node must match its key
|
||||
# (it may also be omitted and thus Coyote fills it in from the key).
|
||||
#
|
||||
# Node types: agent | script | approval | input | llm | rag | map | end
|
||||
# ---------------------------------------------------------------------------
|
||||
nodes:
|
||||
|
||||
# --- llm node -----------------------------------------------------------
|
||||
# A one-shot LLM call (with an optional bounded tool-call loop). Runs in a
|
||||
# fresh isolated context. Tools are strictly opt-in (see `tools`).
|
||||
triage:
|
||||
id: triage
|
||||
type: llm
|
||||
description: Classify the research request and extract its topic.
|
||||
instructions: | # Optional system prompt (templated against state)
|
||||
You triage research requests for a {{audience}} audience.
|
||||
prompt: | # Required user prompt (templated against state)
|
||||
Classify this request and extract the core research topic:
|
||||
{{initial_prompt}}
|
||||
tools: [] # Tool whitelist. Omitted or [] = no tools at all.
|
||||
# A list narrows to exactly those entries.
|
||||
output_schema: # Optional JSON Schema. The output is parsed to JSON
|
||||
type: object # and its top-level object keys auto-merge into state
|
||||
properties: # (so `topic` / `needs_deep_dive` become {{topic}} etc).
|
||||
topic: { type: string }
|
||||
needs_deep_dive: { type: boolean }
|
||||
required: [topic, needs_deep_dive]
|
||||
state_updates: # {{output}} = this node's result (here, the parsed object)
|
||||
triage_result: "{{output}}"
|
||||
# --- Polymorphic `next` -----------------------------------------------
|
||||
# A single string runs the next node sequentially (e.g. `next: retrieve`).
|
||||
# A list runs all listed nodes in parallel as one BSP super-step
|
||||
# (for more info on BSP, see https://en.wikipedia.org/wiki/Bulk_synchronous_parallel).
|
||||
# Their writes are merged via `reducers:` at the join. Branches converge
|
||||
# implicitly when they all route to the same downstream node (here,
|
||||
# `synthesize`). See the diamond:
|
||||
#
|
||||
# triage
|
||||
# / \
|
||||
# retrieve web_search (run concurrently)
|
||||
# \ /
|
||||
# synthesize (join; fires once after both finish)
|
||||
next: [retrieve, web_search]
|
||||
|
||||
# --- rag node (parallel branch 1 of the diamond) ------------------------
|
||||
# Hybrid (vector + keyword) retrieval against a per-node knowledge base.
|
||||
# The knowledge base is built once, at agent load time, into
|
||||
# <agent-dir>/retrieve.yaml (named after this node's id).
|
||||
retrieve:
|
||||
id: retrieve
|
||||
type: rag
|
||||
documents: # Required. Files, directories, URLs, loader paths.
|
||||
- ./knowledge/ # relative paths resolve against the agent directory
|
||||
- https://example.com/reference
|
||||
query: "{{topic}}" # Retrieval query (templated). Default: {{initial_prompt}}.
|
||||
top_k: 5 # Chunks to retrieve. Default = the KB's own top_k.
|
||||
timeout: 120 # Retrieval timeout in seconds. Default 120.
|
||||
# Knowledge-base build config (optional; used only when the KB is first
|
||||
# built). When embedding_model + chunk_size + chunk_overlap are all set,
|
||||
# the KB builds with no interactive prompts (works in non-interactive runs).
|
||||
embedding_model: openai:text-embedding-3-small
|
||||
chunk_size: 1000
|
||||
chunk_overlap: 100
|
||||
reranker_model: null # Optional reranker for hybrid-search results
|
||||
batch_size: 100 # Optional embedding-request batch size
|
||||
state_updates: # {{output}} = { context: <str>, sources: [<path>, ...] }
|
||||
context: "{{output.context}}" # writes `context` -> `reducers.context = concat`
|
||||
sources: "{{output.sources}}" # writes `sources` -> `reducers.sources = append`
|
||||
next: synthesize # Joins with web_search at `synthesize`.
|
||||
|
||||
# --- llm node (parallel branch 2 of the diamond) ------------------------
|
||||
# Runs concurrently with `retrieve`. Both branches write `context` and
|
||||
# `sources`; the validator confirms both keys have a reducer declared, and
|
||||
# the BSP scheduler merges them at the join.
|
||||
web_search:
|
||||
id: web_search
|
||||
type: llm
|
||||
instructions: "You are a web researcher. Cite every claim."
|
||||
prompt: "Web research: {{topic}}. Return findings and sources."
|
||||
tools:
|
||||
- web_search_coyote
|
||||
- mcp:ddg-search
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
context: { type: string }
|
||||
sources:
|
||||
type: array
|
||||
items: { type: string }
|
||||
required: [context, sources]
|
||||
# When `output_schema` is set, top-level keys auto-merge into state, so
|
||||
# `context` and `sources` are produced without needing `state_updates`.
|
||||
next: synthesize # Joins with retrieve at `synthesize`.
|
||||
|
||||
# --- script node (the diamond's join; also dispatches) -----------------
|
||||
# Runs a .sh / .py / .ts script. The script receives state via the
|
||||
# GRAPH_STATE env var (inline JSON) or GRAPH_STATE_FILE (path to a JSON
|
||||
# file, used when state exceeds 32 KiB). Exactly one is set. It must print
|
||||
# a single JSON object on stdout: keys merge into state, and the reserved
|
||||
# `_next` key (if present) overrides routing.
|
||||
#
|
||||
# The script also receives these env vars (parity with bash tools called
|
||||
# from normal agents):
|
||||
# GRAPH_STATE / GRAPH_STATE_FILE state payload (one of the two is set)
|
||||
# LLM_ROOT_DIR coyote config dir
|
||||
# LLM_PROMPT_UTILS_FILE path to .shared/prompt-utils.sh
|
||||
# LLM_AGENT_DATA_DIR this agent's data directory
|
||||
# LLM_AGENT_VAR_<NAME> one per declared `variables:` entry
|
||||
# PATH with coyote's functions bin dir prepended
|
||||
# CLICOLOR_FORCE / FORCE_COLOR so child tools emit ANSI colors
|
||||
# The script's working directory is coyote's invocation CWD (not the agent
|
||||
# directory), matching the behavior of bash tools.
|
||||
#
|
||||
# This node fires once: after both `retrieve` and `web_search` finish.
|
||||
# The BSP scheduler dedups the two incoming edges into a single frontier
|
||||
# entry, applies the staged branch writes through the reducers, then runs
|
||||
# this node against the merged state. Inside the script, `context` is the
|
||||
# concatenated text of both branches and `sources` is the combined list.
|
||||
synthesize:
|
||||
id: synthesize
|
||||
type: script
|
||||
script: scripts/synthesize.py # Path relative to the agent directory
|
||||
timeout: 30 # Seconds. Default 30.
|
||||
state_updates: # Applied after the stdout JSON is merged
|
||||
decided_for: "{{topic}}"
|
||||
next: summarize # Default route if the script emits no `_next`
|
||||
fallback: summarize # Route taken if the script fails (crash / bad JSON)
|
||||
# This script is expected to emit `_next: deep_dive` (or `_next: subjects_map`
|
||||
# to demonstrate the map node below), or no `_next` (then `next` is used).
|
||||
# Targets reached only via the script's dynamic `_next` get an
|
||||
# "unreachable" warning from the validator. This is expected for `_next`-routed
|
||||
# targets.
|
||||
|
||||
# --- agent node ---------------------------------------------------------
|
||||
# Spawns a full Coyote sub-agent and waits for it. The child uses its own
|
||||
# tool stack. Agent nodes have no `tools:` field. No schema hint is
|
||||
# injected even when `output_schema` is set (unlike llm nodes).
|
||||
deep_dive:
|
||||
id: deep_dive
|
||||
type: agent
|
||||
agent: deep-research # Name of an existing Coyote agent to spawn
|
||||
prompt: | # User message sent to the child (templated)
|
||||
Research {{topic}} in depth. Existing context:
|
||||
{{context}}
|
||||
timeout: 600 # Optional wall-clock cap, seconds. Default 300.
|
||||
output_schema: # Optional. Same extraction as llm nodes
|
||||
type: object
|
||||
properties:
|
||||
summary: { type: string }
|
||||
findings:
|
||||
type: array
|
||||
items: { type: string }
|
||||
required: [summary, findings]
|
||||
state_updates:
|
||||
research: "{{output}}"
|
||||
next: review # Required for agent nodes
|
||||
|
||||
# --- map node (Dynamic fan-out. Think: LangGraph's `Send` API) ----------------
|
||||
# Spawns one parallel sub-branch per item in `over`. Each sub-branch runs
|
||||
# the node referenced by `branch:` with the item bound to `as:`. Outputs
|
||||
# collect into the array named by `collect_into:`, preserving input order.
|
||||
#
|
||||
# Reach via `synthesize`'s `_next: subjects_map`. The producer is expected
|
||||
# to have written a list at `subjects` (e.g. an upstream LLM node with an
|
||||
# `output_schema` returning {"subjects": ["a", "b", "c"]}).
|
||||
subjects_map:
|
||||
id: subjects_map
|
||||
type: map
|
||||
over: "{{subjects}}" # Required. List expression resolved from state.
|
||||
# Empty list is allowed. It means no branches spawn,
|
||||
# and thus `collect_into` is written as [].
|
||||
as: subject # Required. Per-branch state key holding the
|
||||
# current item. Read with {{subject}} inside
|
||||
# the branch node's prompt.
|
||||
branch: research_subject # Required. Node id to invoke per item.
|
||||
# Must point to an llm | agent | rag | script
|
||||
# node satisfying the map branch contract:
|
||||
# - no `next:` (atomic, joined at map exit)
|
||||
# - no `state_updates:` other than via the
|
||||
# map's `collect_into` channel
|
||||
# - no `output_schema:` (top-level merge
|
||||
# would clash with collect_into)
|
||||
# Validator enforces all three.
|
||||
collect_into: subject_findings # Required. State key for the array of
|
||||
# per-branch outputs, in input order
|
||||
# (not spawn-finish order).
|
||||
max_concurrency: 3 # Optional per-map cap. Defaults to
|
||||
# settings.max_concurrency above.
|
||||
output_key: output # Optional. State key the branch's output
|
||||
# appears under. Default "output". Useful
|
||||
# only if the branch reads its own bound
|
||||
# name back (rare).
|
||||
next: aggregate_subjects # Where to go after all sub-branches finish.
|
||||
|
||||
# Branch node for subjects_map. Each invocation receives a different
|
||||
# `subject` in state. The branch is "atomic", meaning it cannot route on
|
||||
# its own; the surrounding `map` joins after all invocations finish.
|
||||
research_subject:
|
||||
id: research_subject
|
||||
type: llm
|
||||
instructions: "Research one subject deeply for a {{audience}} audience."
|
||||
prompt: "Research {{subject}}: pull the key facts and one citation."
|
||||
tools:
|
||||
- web_search_coyote
|
||||
# No `next:`, `state_updates:`, or `output_schema:` here. Map branches
|
||||
# have a strict contract (see `subjects_map.branch` comment).
|
||||
|
||||
# Aggregator that runs after the map joins. Reads the collected list.
|
||||
aggregate_subjects:
|
||||
id: aggregate_subjects
|
||||
type: llm
|
||||
instructions: "Combine N per-subject reports into one cohesive summary."
|
||||
prompt: |
|
||||
Per-subject reports (in original input order):
|
||||
{{subject_findings}}
|
||||
state_updates:
|
||||
research: "{{output}}"
|
||||
next: review
|
||||
|
||||
# --- llm node with a narrowed tool whitelist ----------------------------
|
||||
summarize:
|
||||
id: summarize
|
||||
type: llm
|
||||
instructions: "You write concise research summaries for a {{audience}} audience."
|
||||
prompt: "Summarize the topic {{topic}}, using your tools as needed."
|
||||
tools: # Narrow whitelist: exactly these entries, nothing else
|
||||
- web_search_coyote # an exact global-tool / custom-tool name
|
||||
- mcp:ddg-search # `mcp:<server>` includes that server's functions
|
||||
model: claude:claude-haiku-4-5 # Optional per-node model override
|
||||
temperature: 0.3 # Optional per-node sampling override
|
||||
max_attempts: 2 # Retry count on transient errors only. Default 1.
|
||||
max_iterations: 10 # Tool-call-loop turn cap. Default 10.
|
||||
fallback: review # Route here if all attempts fail
|
||||
timeout: 300 # Optional node wall-clock cap, seconds (unset = no timeout)
|
||||
state_updates:
|
||||
research: "{{output}}"
|
||||
next: review # Required for llm nodes: the success route
|
||||
|
||||
# --- approval node ------------------------------------------------------
|
||||
# Human-in-the-loop checkpoint. `user__ask` always offers a free-form
|
||||
# "type your own answer" option, so `on_other` is required.
|
||||
review:
|
||||
id: review
|
||||
type: approval
|
||||
question: |
|
||||
Proposed research result for {{topic}}:
|
||||
{{research}}
|
||||
|
||||
Approve?
|
||||
options: # The listed choices shown to the user
|
||||
- "yes"
|
||||
- "no"
|
||||
routes: # Map each listed option to its next node
|
||||
"yes": finalize
|
||||
"no": rejected_end
|
||||
on_other: refine # Required: route for ANY answer not in `routes`
|
||||
state_updates:
|
||||
decision: "{{choice}}" # {{choice}} = the chosen option or the free-form text
|
||||
|
||||
# --- input node ---------------------------------------------------------
|
||||
# Collects a free-form string from the user.
|
||||
refine:
|
||||
id: refine
|
||||
type: input
|
||||
question: "What should be changed about the research result?"
|
||||
default: "tighten the summary" # Optional: used if the user submits empty input.
|
||||
# Note: a substituted default is not re-validated,
|
||||
# so make sure it would satisfy `validation`.
|
||||
validation: "len(input) > 0" # Optional length predicate: len(input) <op> N,
|
||||
# <op> in > >= < <= == . Length only -- no regex.
|
||||
state_updates:
|
||||
refinement: "{{input}}" # {{input}} = the user's text
|
||||
next: finalize # Required for input nodes: the success route
|
||||
|
||||
# --- llm node (final synthesis) -----------------------------------------
|
||||
finalize:
|
||||
id: finalize
|
||||
type: llm
|
||||
prompt: |
|
||||
Produce the final research report for {{topic}}.
|
||||
Result so far: {{research}}
|
||||
Requested refinement (if any): {{refinement}}
|
||||
state_updates:
|
||||
final_report: "{{output}}"
|
||||
next: done
|
||||
|
||||
# --- end nodes ----------------------------------------------------------
|
||||
# Terminate the graph. `output` (templated, lenient interpolation) becomes
|
||||
# the graph's final result. A graph needs at least one `end` node.
|
||||
done:
|
||||
id: done
|
||||
type: end
|
||||
state_updates: # Optional: applied before `output` is rendered
|
||||
status: "completed"
|
||||
output: |
|
||||
[{{status}}] {{final_report}}
|
||||
|
||||
Sources: {{sources}}
|
||||
|
||||
rejected_end:
|
||||
id: rejected_end
|
||||
type: end
|
||||
output: "Research on {{topic}} was not approved."
|
||||
@@ -18,7 +18,7 @@ fmt:
|
||||
cargo fmt --all
|
||||
|
||||
# Build the project for the current system architecture
|
||||
# (Gets stored at ./target/[debug|release]/loki)
|
||||
# (Gets stored at ./target/[debug|release]/coyote)
|
||||
[group: 'build']
|
||||
[arg('build_type', pattern="debug|release")]
|
||||
build build_type='debug':
|
||||
|
||||
@@ -487,14 +487,6 @@
|
||||
thinking:
|
||||
type: enabled
|
||||
budget_tokens: 16000
|
||||
- name: claude-3-5-haiku-20241022
|
||||
max_input_tokens: 200000
|
||||
max_output_tokens: 8192
|
||||
require_max_tokens: true
|
||||
input_price: 0.8
|
||||
output_price: 4
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
|
||||
# Links:
|
||||
# - https://docs.mistral.ai/getting-started/models/models_overview/
|
||||
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "loki",
|
||||
"name": "coyote",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<#
|
||||
loki installer (Windows/PowerShell 5+ and PowerShell 7)
|
||||
coyote installer (Windows/PowerShell 5+ and PowerShell 7)
|
||||
|
||||
Examples:
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/Dark-Alex-17/loki/main/scripts/install_loki.ps1 | iex"
|
||||
pwsh -c "irm https://raw.githubusercontent.com/Dark-Alex-17/loki/main/scripts/install_loki.ps1 | iex -Version vX.Y.Z"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/Dark-Alex-17/coyote/main/scripts/install_coyote.ps1 | iex"
|
||||
pwsh -c "irm https://raw.githubusercontent.com/Dark-Alex-17/coyote/main/scripts/install_coyote.ps1 | iex -Version vX.Y.Z"
|
||||
|
||||
Parameters:
|
||||
-Version <tag> (default: latest)
|
||||
-BinDir <path> (default: %LOCALAPPDATA%\loki\bin on Windows; ~/.local/bin on *nix PowerShell)
|
||||
-BinDir <path> (default: %LOCALAPPDATA%\coyote\bin on Windows; ~/.local/bin on *nix PowerShell)
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Version = $env:LOKI_VERSION,
|
||||
[string]$Version = $env:COYOTE_VERSION,
|
||||
[string]$BinDir = $env:BIN_DIR
|
||||
)
|
||||
|
||||
$Repo = 'Dark-Alex-17/loki'
|
||||
$Repo = 'Dark-Alex-17/coyote'
|
||||
|
||||
function Write-Info($msg) { Write-Host "[loki-install] $msg" }
|
||||
function Write-Info($msg) { Write-Host "[coyote-install] $msg" }
|
||||
function Fail($msg) { Write-Error $msg; exit 1 }
|
||||
|
||||
Add-Type -AssemblyName System.Runtime
|
||||
@@ -38,7 +38,7 @@ switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) {
|
||||
}
|
||||
|
||||
if (-not $BinDir) {
|
||||
if ($isWin) { $BinDir = Join-Path $env:LOCALAPPDATA 'loki\bin' }
|
||||
if ($isWin) { $BinDir = Join-Path $env:LOCALAPPDATA 'coyote\bin' }
|
||||
else { $home = $env:HOME; if (-not $home) { $home = (Get-Item -Path ~).FullName }; $BinDir = Join-Path $home '.local/bin' }
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
|
||||
@@ -49,23 +49,23 @@ $apiBase = "https://api.github.com/repos/$Repo/releases"
|
||||
$relUrl = if ($Version) { "$apiBase/tags/$Version" } else { "$apiBase/latest" }
|
||||
Write-Info "Fetching release: $relUrl"
|
||||
try {
|
||||
$release = Invoke-RestMethod -UseBasicParsing -Headers @{ 'User-Agent' = 'loki-installer' } -Uri $relUrl -Method GET
|
||||
$release = Invoke-RestMethod -UseBasicParsing -Headers @{ 'User-Agent' = 'coyote-installer' } -Uri $relUrl -Method GET
|
||||
} catch { Fail "Failed to fetch release metadata. $_" }
|
||||
if (-not $release.assets) { Fail "No assets found in the release." }
|
||||
|
||||
$candidates = @()
|
||||
if ($os -eq 'windows') {
|
||||
if ($arch -eq 'x86_64') { $candidates += 'loki-x86_64-pc-windows-msvc.zip' }
|
||||
else { $candidates += 'loki-aarch64-pc-windows-msvc.zip' }
|
||||
if ($arch -eq 'x86_64') { $candidates += 'coyote-x86_64-pc-windows-msvc.zip' }
|
||||
else { $candidates += 'coyote-aarch64-pc-windows-msvc.zip' }
|
||||
} elseif ($os -eq 'darwin') {
|
||||
if ($arch -eq 'x86_64') { $candidates += 'loki-x86_64-apple-darwin.tar.gz' }
|
||||
else { $candidates += 'loki-aarch64-apple-darwin.tar.gz' }
|
||||
if ($arch -eq 'x86_64') { $candidates += 'coyote-x86_64-apple-darwin.tar.gz' }
|
||||
else { $candidates += 'coyote-aarch64-apple-darwin.tar.gz' }
|
||||
} elseif ($os -eq 'linux') {
|
||||
if ($arch -eq 'x86_64') {
|
||||
$candidates += 'loki-x86_64-unknown-linux-gnu.tar.gz'
|
||||
$candidates += 'loki-x86_64-unknown-linux-musl.tar.gz'
|
||||
$candidates += 'coyote-x86_64-unknown-linux-gnu.tar.gz'
|
||||
$candidates += 'coyote-x86_64-unknown-linux-musl.tar.gz'
|
||||
} else {
|
||||
$candidates += 'loki-aarch64-unknown-linux-musl.tar.gz'
|
||||
$candidates += 'coyote-aarch64-unknown-linux-musl.tar.gz'
|
||||
}
|
||||
} else {
|
||||
Fail "Unsupported OS for this installer: $os"
|
||||
@@ -84,9 +84,9 @@ if (-not $asset) {
|
||||
Write-Info "Selected asset: $($asset.name)"
|
||||
Write-Info "Download URL: $($asset.browser_download_url)"
|
||||
|
||||
$tmp = New-Item -ItemType Directory -Force -Path ([IO.Path]::Combine([IO.Path]::GetTempPath(), "loki-$(Get-Random)"))
|
||||
$tmp = New-Item -ItemType Directory -Force -Path ([IO.Path]::Combine([IO.Path]::GetTempPath(), "coyote-$(Get-Random)"))
|
||||
$archive = Join-Path $tmp.FullName 'asset'
|
||||
try { Invoke-WebRequest -UseBasicParsing -Headers @{ 'User-Agent' = 'loki-installer' } -Uri $asset.browser_download_url -OutFile $archive } catch { Fail "Failed to download asset. $_" }
|
||||
try { Invoke-WebRequest -UseBasicParsing -Headers @{ 'User-Agent' = 'coyote-installer' } -Uri $asset.browser_download_url -OutFile $archive } catch { Fail "Failed to download asset. $_" }
|
||||
|
||||
$extractDir = Join-Path $tmp.FullName 'extract'; New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
|
||||
|
||||
@@ -107,14 +107,14 @@ if ($asset.name -match '\.zip$') {
|
||||
|
||||
$bin = $null
|
||||
Get-ChildItem -Recurse -File $extractDir | ForEach-Object {
|
||||
if ($isWin) { if ($_.Name -ieq 'loki.exe') { $bin = $_.FullName } }
|
||||
else { if ($_.Name -ieq 'loki') { $bin = $_.FullName } }
|
||||
if ($isWin) { if ($_.Name -ieq 'coyote.exe') { $bin = $_.FullName } }
|
||||
else { if ($_.Name -ieq 'coyote') { $bin = $_.FullName } }
|
||||
}
|
||||
if (-not $bin) { Fail "Could not find loki binary inside the archive." }
|
||||
if (-not $bin) { Fail "Could not find coyote binary inside the archive." }
|
||||
|
||||
if (-not $isWin) { try { & chmod +x -- $bin } catch {} }
|
||||
|
||||
$exec = if ($isWin) { 'loki.exe'} else { 'loki' }
|
||||
$exec = if ($isWin) { 'coyote.exe'} else { 'coyote' }
|
||||
$dest = Join-Path $BinDir $exec
|
||||
Copy-Item -Force $bin $dest
|
||||
Write-Info "Installed: $dest"
|
||||
@@ -135,5 +135,5 @@ if ($isWin) {
|
||||
}
|
||||
}
|
||||
|
||||
Write-Info "Done. Try: loki --help"
|
||||
Write-Info "Done. Try: coyote --help"
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# loki installer (Linux/macOS)
|
||||
# coyote installer (Linux/macOS)
|
||||
#
|
||||
# Usage examples:
|
||||
# curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/loki/main/scripts/install_loki.sh | bash
|
||||
# curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/loki/main/scripts/install_loki.sh | bash -s -- --version vX.Y.Z
|
||||
# BIN_DIR="$HOME/.local/bin" bash scripts/install_loki.sh
|
||||
# curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/coyote/main/scripts/install_coyote.sh | bash
|
||||
# curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/coyote/main/scripts/install_coyote.sh | bash -s -- --version vX.Y.Z
|
||||
# BIN_DIR="$HOME/.local/bin" bash scripts/install_coyote.sh
|
||||
#
|
||||
# Flags / Env:
|
||||
# --version <tag> Release tag (default: latest). Or set LOKI_VERSION.
|
||||
# --version <tag> Release tag (default: latest). Or set COYOTE_VERSION.
|
||||
# --bin-dir <dir> Install directory (default: /usr/local/bin or ~/.local/bin). Or set BIN_DIR.
|
||||
|
||||
REPO="Dark-Alex-17/loki"
|
||||
VERSION="${LOKI_VERSION:-}"
|
||||
REPO="Dark-Alex-17/coyote"
|
||||
VERSION="${COYOTE_VERSION:-}"
|
||||
BIN_DIR="${BIN_DIR:-}"
|
||||
|
||||
usage() {
|
||||
echo "loki installer (Linux/macOS)"
|
||||
echo "coyote installer (Linux/macOS)"
|
||||
echo
|
||||
echo "Options:"
|
||||
echo " --version <tag> Release tag (default: latest)"
|
||||
@@ -44,7 +44,7 @@ fi
|
||||
mkdir -p "${BIN_DIR}"
|
||||
|
||||
log() {
|
||||
echo "[loki-install] $*"
|
||||
echo "[coyote-install] $*"
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
@@ -92,9 +92,9 @@ fi
|
||||
|
||||
http_get() {
|
||||
if [[ "$DL" == "curl" ]]; then
|
||||
curl -fsSL -H 'User-Agent: loki-installer' "$1"
|
||||
curl -fsSL -H 'User-Agent: coyote-installer' "$1"
|
||||
else
|
||||
wget -qO- --header='User-Agent: loki-installer' "$1"
|
||||
wget -qO- --header='User-Agent: coyote-installer' "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -111,9 +111,9 @@ fi
|
||||
ASSET_CANDIDATES=()
|
||||
if [[ "$OS" == "darwin" ]]; then
|
||||
if [[ "$ARCH" == "x86_64" ]]; then
|
||||
ASSET_CANDIDATES+=("loki-x86_64-apple-darwin.tar.gz")
|
||||
ASSET_CANDIDATES+=("coyote-x86_64-apple-darwin.tar.gz")
|
||||
else
|
||||
ASSET_CANDIDATES+=("loki-aarch64-apple-darwin.tar.gz")
|
||||
ASSET_CANDIDATES+=("coyote-aarch64-apple-darwin.tar.gz")
|
||||
fi
|
||||
elif [[ "$OS" == "linux" ]]; then
|
||||
if [[ "$ARCH" == "x86_64" ]]; then
|
||||
@@ -122,12 +122,12 @@ elif [[ "$OS" == "linux" ]]; then
|
||||
if ldd --version 2>&1 | grep -qi glibc; then LIBC="gnu"; fi
|
||||
|
||||
if [[ "$LIBC" == "gnu" ]]; then
|
||||
ASSET_CANDIDATES+=("loki-x86_64-unknown-linux-gnu.tar.gz")
|
||||
ASSET_CANDIDATES+=("coyote-x86_64-unknown-linux-gnu.tar.gz")
|
||||
fi
|
||||
|
||||
ASSET_CANDIDATES+=("loki-x86_64-unknown-linux-musl.tar.gz")
|
||||
ASSET_CANDIDATES+=("coyote-x86_64-unknown-linux-musl.tar.gz")
|
||||
else
|
||||
ASSET_CANDIDATES+=("loki-aarch64-unknown-linux-musl.tar.gz")
|
||||
ASSET_CANDIDATES+=("coyote-aarch64-unknown-linux-musl.tar.gz")
|
||||
fi
|
||||
else
|
||||
echo "Error: unsupported OS for this installer: $OS" >&2; exit 1
|
||||
@@ -170,9 +170,9 @@ log "Download URL: $ASSET_URL"
|
||||
|
||||
ARCHIVE="$TMPDIR/asset"
|
||||
if [[ "$DL" == "curl" ]]; then
|
||||
curl -fL -H 'User-Agent: loki-installer' "$ASSET_URL" -o "$ARCHIVE"
|
||||
curl -fL -H 'User-Agent: coyote-installer' "$ASSET_URL" -o "$ARCHIVE"
|
||||
else
|
||||
wget -q --header='User-Agent: loki-installer' "$ASSET_URL" -O "$ARCHIVE"
|
||||
wget -q --header='User-Agent: coyote-installer' "$ASSET_URL" -O "$ARCHIVE"
|
||||
fi
|
||||
|
||||
WORK="$TMPDIR/work"; mkdir -p "$WORK"
|
||||
@@ -192,21 +192,21 @@ fi
|
||||
BIN_PATH=""
|
||||
while IFS= read -r -d '' f; do
|
||||
base=$(basename "$f")
|
||||
if [[ "$base" == "loki" ]]; then
|
||||
if [[ "$base" == "coyote" ]]; then
|
||||
BIN_PATH="$f"
|
||||
break
|
||||
fi
|
||||
done < <(find "$EXTRACTED_DIR" -type f -print0)
|
||||
|
||||
if [[ -z "$BIN_PATH" ]]; then
|
||||
echo "Error: could not find 'loki' binary in the archive" >&2
|
||||
echo "Error: could not find 'coyote' binary in the archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x "$BIN_PATH"
|
||||
install -m 0755 "$BIN_PATH" "${BIN_DIR}/loki"
|
||||
install -m 0755 "$BIN_PATH" "${BIN_DIR}/coyote"
|
||||
|
||||
log "Installed: ${BIN_DIR}/loki"
|
||||
log "Installed: ${BIN_DIR}/coyote"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":${BIN_DIR}:"*) ;;
|
||||
@@ -216,5 +216,5 @@ case ":$PATH:" in
|
||||
;;
|
||||
esac
|
||||
|
||||
log "Done. Try: loki --help"
|
||||
log "Done. Try: coyote --help"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
_loki_bash() {
|
||||
_coyote_bash() {
|
||||
if [[ -n "$READLINE_LINE" ]]; then
|
||||
READLINE_LINE=$(loki -e "$READLINE_LINE")
|
||||
READLINE_LINE=$(coyote -e "$READLINE_LINE")
|
||||
READLINE_POINT=${#READLINE_LINE}
|
||||
fi
|
||||
}
|
||||
bind -x '"\ee": _loki_bash'
|
||||
bind -x '"\ee": _coyote_bash'
|
||||
@@ -1,7 +1,7 @@
|
||||
fn _loki_elvish {
|
||||
fn _coyote_elvish {
|
||||
var line = (edit:current-command)
|
||||
var new-line = (loki -e $line)
|
||||
var new-line = (coyote -e $line)
|
||||
edit:replace-input $new-line
|
||||
}
|
||||
|
||||
edit:insert:binding[Alt-e] = $_loki_elvish
|
||||
edit:insert:binding[Alt-e] = $_coyote_elvish
|
||||
@@ -1,9 +1,9 @@
|
||||
function _loki_fish
|
||||
function _coyote_fish
|
||||
set -l _old (commandline)
|
||||
if test -n $_old
|
||||
echo -n "⌛"
|
||||
commandline -f repaint
|
||||
commandline (loki -e $_old)
|
||||
commandline (coyote -e $_old)
|
||||
end
|
||||
end
|
||||
bind \ee _loki_fish
|
||||
bind \ee _coyote_fish
|
||||
@@ -1,20 +1,20 @@
|
||||
def _loki_nushell [] {
|
||||
def _coyote_nushell [] {
|
||||
let _prev = (commandline)
|
||||
if ($_prev != "") {
|
||||
print '⌛'
|
||||
commandline edit -r (loki -e $_prev)
|
||||
commandline edit -r (coyote -e $_prev)
|
||||
}
|
||||
}
|
||||
|
||||
$env.config.keybindings = ($env.config.keybindings | append {
|
||||
name: loki_integration
|
||||
name: coyote_integration
|
||||
modifier: alt
|
||||
keycode: char_e
|
||||
mode: [emacs, vi_insert]
|
||||
event:[
|
||||
{
|
||||
send: executehostcommand,
|
||||
cmd: "_loki_nushell"
|
||||
cmd: "_coyote_nushell"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ Set-PSReadLineKeyHandler -Chord "alt+e" -ScriptBlock {
|
||||
[Microsoft.PowerShell.PSConsoleReadline]::GetBufferState([ref]$_old, [ref]$null)
|
||||
if ($_old) {
|
||||
[Microsoft.PowerShell.PSConsoleReadLine]::Insert('⌛')
|
||||
$_new = (loki -e $_old)
|
||||
$_new = (coyote -e $_old)
|
||||
[Microsoft.PowerShell.PSConsoleReadLine]::DeleteLine()
|
||||
[Microsoft.PowerShell.PSConsoleReadline]::Insert($_new)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
_loki_zsh() {
|
||||
_coyote_zsh() {
|
||||
if [[ -n "$BUFFER" ]]; then
|
||||
local _old=$BUFFER
|
||||
BUFFER+="⌛"
|
||||
zle -I && zle redisplay
|
||||
BUFFER=$(loki -e "$_old")
|
||||
BUFFER=$(coyote -e "$_old")
|
||||
zle end-of-line
|
||||
fi
|
||||
}
|
||||
zle -N _loki_zsh
|
||||
bindkey '\ee' _loki_zsh
|
||||
zle -N _coyote_zsh
|
||||
bindkey '\ee' _coyote_zsh
|
||||
+39
-8
@@ -1,13 +1,15 @@
|
||||
use crate::client::{ModelType, list_models};
|
||||
use crate::config::paths;
|
||||
use crate::config::{AppConfig, Config, list_agents, list_sessions};
|
||||
use crate::utils::list_file_names;
|
||||
use crate::vault::Vault;
|
||||
use clap_complete::{CompletionCandidate, Shell, generate};
|
||||
use clap_complete_nushell::Nushell;
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
|
||||
const LOKI_CLI_NAME: &str = "loki";
|
||||
const COYOTE_CLI_NAME: &str = "coyote";
|
||||
|
||||
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
|
||||
pub enum ShellCompletion {
|
||||
@@ -22,12 +24,14 @@ pub enum ShellCompletion {
|
||||
impl ShellCompletion {
|
||||
pub fn generate_completions(self, cmd: &mut clap::Command) {
|
||||
match self {
|
||||
Self::Bash => generate(Shell::Bash, cmd, LOKI_CLI_NAME, &mut io::stdout()),
|
||||
Self::Elvish => generate(Shell::Elvish, cmd, LOKI_CLI_NAME, &mut io::stdout()),
|
||||
Self::Fish => generate(Shell::Fish, cmd, LOKI_CLI_NAME, &mut io::stdout()),
|
||||
Self::PowerShell => generate(Shell::PowerShell, cmd, LOKI_CLI_NAME, &mut io::stdout()),
|
||||
Self::Zsh => generate(Shell::Zsh, cmd, LOKI_CLI_NAME, &mut io::stdout()),
|
||||
Self::Nushell => generate(Nushell, cmd, LOKI_CLI_NAME, &mut io::stdout()),
|
||||
Self::Bash => generate(Shell::Bash, cmd, COYOTE_CLI_NAME, &mut io::stdout()),
|
||||
Self::Elvish => generate(Shell::Elvish, cmd, COYOTE_CLI_NAME, &mut io::stdout()),
|
||||
Self::Fish => generate(Shell::Fish, cmd, COYOTE_CLI_NAME, &mut io::stdout()),
|
||||
Self::PowerShell => {
|
||||
generate(Shell::PowerShell, cmd, COYOTE_CLI_NAME, &mut io::stdout())
|
||||
}
|
||||
Self::Zsh => generate(Shell::Zsh, cmd, COYOTE_CLI_NAME, &mut io::stdout()),
|
||||
Self::Nushell => generate(Nushell, cmd, COYOTE_CLI_NAME, &mut io::stdout()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,9 +98,36 @@ pub(super) fn macro_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_agent_from_args() -> Option<String> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = &args[i];
|
||||
|
||||
if let Some(value) = arg.strip_prefix("--agent=") {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
|
||||
if (arg == "--agent" || arg == "-a") && i + 1 < args.len() {
|
||||
return Some(args[i + 1].clone());
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
let cur = current.to_string_lossy();
|
||||
list_sessions()
|
||||
|
||||
let sessions = if let Some(agent_name) = extract_agent_from_args() {
|
||||
let sessions_dir = paths::agent_data_dir(&agent_name).join("sessions");
|
||||
list_file_names(sessions_dir, ".yaml")
|
||||
} else {
|
||||
list_sessions()
|
||||
};
|
||||
|
||||
sessions
|
||||
.into_iter()
|
||||
.filter(|s| s.starts_with(&*cur))
|
||||
.map(CompletionCandidate::new)
|
||||
|
||||
+54
-8
@@ -4,9 +4,10 @@ use crate::cli::completer::{
|
||||
ShellCompletion, agent_completer, macro_completer, model_completer, rag_completer,
|
||||
role_completer, secrets_completer, session_completer,
|
||||
};
|
||||
use crate::config::{AssetCategory, InstallFilter};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::ValueHint;
|
||||
use clap::{Parser, crate_authors, crate_description, crate_name, crate_version};
|
||||
use clap::{Parser, crate_authors, crate_description, crate_version};
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use is_terminal::IsTerminal;
|
||||
use std::io::{Read, stdin};
|
||||
@@ -14,7 +15,7 @@ use std::io::{Read, stdin};
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
#[command(
|
||||
name = crate_name!(),
|
||||
name = "coyote",
|
||||
author = crate_authors!(),
|
||||
version = crate_version!(),
|
||||
about = crate_description!(),
|
||||
@@ -82,6 +83,18 @@ pub struct Cli {
|
||||
/// Build all configured Bash tool scripts
|
||||
#[arg(long)]
|
||||
pub build_tools: bool,
|
||||
/// Reinstall bundled assets, overwriting any local changes
|
||||
#[arg(long, value_name = "CATEGORY", value_enum)]
|
||||
pub install: Option<AssetCategory>,
|
||||
/// Install assets from a remote git repository (URL may be suffixed with #<ref>)
|
||||
#[arg(long, value_name = "GIT_URL")]
|
||||
pub install_from: Option<String>,
|
||||
/// Restrict --install-from to a single asset category
|
||||
#[arg(long, value_name = "CATEGORY", value_enum, requires = "install_from")]
|
||||
pub filter: Option<InstallFilter>,
|
||||
/// Overwrite all conflicts without prompting (used with --install-from)
|
||||
#[arg(long, requires = "install_from")]
|
||||
pub install_force: bool,
|
||||
/// Sync models updates
|
||||
#[arg(long)]
|
||||
pub sync_models: bool,
|
||||
@@ -112,19 +125,19 @@ pub struct Cli {
|
||||
/// Disable colored log output
|
||||
#[arg(long, requires = "tail_logs")]
|
||||
pub disable_log_colors: bool,
|
||||
/// Add a secret to the Loki vault
|
||||
/// Add a secret to the Coyote vault
|
||||
#[arg(long, value_name = "SECRET_NAME", exclusive = true)]
|
||||
pub add_secret: Option<String>,
|
||||
/// Decrypt a secret from the Loki vault and print the plaintext
|
||||
/// Decrypt a secret from the Coyote vault and print the plaintext
|
||||
#[arg(long, value_name = "SECRET_NAME", exclusive = true, add = ArgValueCompleter::new(secrets_completer))]
|
||||
pub get_secret: Option<String>,
|
||||
/// Update an existing secret in the Loki vault
|
||||
/// Update an existing secret in the Coyote vault
|
||||
#[arg(long, value_name = "SECRET_NAME", exclusive = true, add = ArgValueCompleter::new(secrets_completer))]
|
||||
pub update_secret: Option<String>,
|
||||
/// Delete a secret from the Loki vault
|
||||
/// Delete a secret from the Coyote vault
|
||||
#[arg(long, value_name = "SECRET_NAME", exclusive = true, add = ArgValueCompleter::new(secrets_completer))]
|
||||
pub delete_secret: Option<String>,
|
||||
/// List all secrets stored in the Loki vault
|
||||
/// List all secrets stored in the Coyote vault
|
||||
#[arg(long, exclusive = true)]
|
||||
pub list_secrets: bool,
|
||||
/// Authenticate with an LLM provider using OAuth (e.g., --authenticate client_name)
|
||||
@@ -133,6 +146,12 @@ pub struct Cli {
|
||||
/// Generate static shell completion scripts
|
||||
#[arg(long, value_name = "SHELL", value_enum)]
|
||||
pub completions: Option<ShellCompletion>,
|
||||
/// Update Coyote to the latest release, or to a specific version
|
||||
#[arg(long, value_name = "VERSION")]
|
||||
pub update: Option<Option<String>>,
|
||||
/// With --update, update even if Coyote was installed via a package manager
|
||||
#[arg(long, requires = "update")]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
@@ -183,7 +202,7 @@ mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
fn parse(args: &[&str]) -> Cli {
|
||||
let mut full_args = vec!["loki"];
|
||||
let mut full_args = vec!["coyote"];
|
||||
full_args.extend_from_slice(args);
|
||||
Cli::try_parse_from(full_args).unwrap()
|
||||
}
|
||||
@@ -392,4 +411,31 @@ mod tests {
|
||||
let cli = parse(&["--macro", "my-macro"]);
|
||||
assert_eq!(cli.macro_name, Some("my-macro".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_update_flag_no_value() {
|
||||
let cli = parse(&["--update"]);
|
||||
|
||||
assert_eq!(cli.update, Some(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_update_flag_with_version() {
|
||||
let cli = parse(&["--update", "v0.4.0"]);
|
||||
|
||||
assert_eq!(cli.update, Some(Some("v0.4.0".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_update_with_force() {
|
||||
let cli = parse(&["--update", "--force"]);
|
||||
|
||||
assert_eq!(cli.update, Some(None));
|
||||
assert!(cli.force);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_force_without_update_fails() {
|
||||
assert!(Cli::try_parse_from(["coyote", "--force"]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+27
-28
@@ -85,7 +85,7 @@ async fn prepare_chat_completions(
|
||||
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
|
||||
if !ready {
|
||||
bail!(
|
||||
"OAuth configured but no tokens found for '{}'. Run: 'loki --authenticate {}' or '.authenticate' in the REPL",
|
||||
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
|
||||
self_.name(),
|
||||
self_.name()
|
||||
);
|
||||
@@ -100,7 +100,7 @@ async fn prepare_chat_completions(
|
||||
request_data.header("x-api-key", api_key);
|
||||
} else {
|
||||
bail!(
|
||||
"No authentication configured for '{}'. Set `api_key` or use `auth: oauth` with `loki --authenticate {}`.",
|
||||
"No authentication configured for '{}'. Set `api_key` or use `auth: oauth` with `coyote --authenticate {}`.",
|
||||
self_.name(),
|
||||
self_.name()
|
||||
);
|
||||
@@ -114,36 +114,35 @@ async fn prepare_chat_completions(
|
||||
///
|
||||
/// This behavior was discovered 2026-03-17.
|
||||
///
|
||||
/// So this function injects the Claude Code system prompt into the request
|
||||
/// body to make it a valid request.
|
||||
/// The prefix must be in its **own** top-level system block. Concatenating it
|
||||
/// with role / session content into a single block causes Anthropic to reject
|
||||
/// the request with `rate_limit_error`. Any pre-existing system content is
|
||||
/// preserved as additional blocks after the prefix.
|
||||
fn inject_oauth_system_prompt(body: &mut Value) {
|
||||
let prefix_block = json!({
|
||||
"type": "text",
|
||||
"text": CLAUDE_CODE_PREFIX,
|
||||
});
|
||||
|
||||
match body.get("system") {
|
||||
Some(Value::String(existing)) => {
|
||||
let existing_block = json!({
|
||||
"type": "text",
|
||||
"text": existing,
|
||||
});
|
||||
body["system"] = json!([prefix_block, existing_block]);
|
||||
}
|
||||
Some(Value::Array(_)) => {
|
||||
if let Some(arr) = body["system"].as_array_mut() {
|
||||
let already_injected = arr
|
||||
.iter()
|
||||
.any(|block| block["text"].as_str() == Some(CLAUDE_CODE_PREFIX));
|
||||
if !already_injected {
|
||||
arr.insert(0, prefix_block);
|
||||
}
|
||||
let existing_blocks: Vec<Value> = match body.get("system") {
|
||||
Some(Value::String(s)) => {
|
||||
if s.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![json!({ "type": "text", "text": s })]
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
body["system"] = json!([prefix_block]);
|
||||
}
|
||||
Some(Value::Array(blocks)) => blocks.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
let already_injected = existing_blocks
|
||||
.first()
|
||||
.and_then(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
.map(|t| t == CLAUDE_CODE_PREFIX)
|
||||
.unwrap_or(false);
|
||||
if already_injected {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut system = vec![json!({ "type": "text", "text": CLAUDE_CODE_PREFIX })];
|
||||
system.extend(existing_blocks);
|
||||
body["system"] = Value::Array(system);
|
||||
}
|
||||
|
||||
pub async fn claude_chat_completions(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
|
||||
use crate::config::paths;
|
||||
use crate::config::{RenderMode, paths};
|
||||
use crate::{
|
||||
config::{AppConfig, Input, RequestContext},
|
||||
function::{FunctionDeclaration, ToolCall, ToolResult, eval_tool_calls},
|
||||
@@ -418,7 +418,8 @@ pub async fn call_chat_completions(
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<(String, Vec<ToolResult>)> {
|
||||
let is_child_agent = ctx.current_depth > 0;
|
||||
let spinner_message = if is_child_agent { "" } else { "Generating" };
|
||||
let suppress_spinner = is_child_agent || ctx.render_mode == RenderMode::Silent;
|
||||
let spinner_message = if suppress_spinner { "" } else { "Generating" };
|
||||
let ret = abortable_run_with_spinner(
|
||||
client.chat_completions(input.clone()),
|
||||
spinner_message,
|
||||
@@ -459,10 +460,14 @@ pub async fn call_chat_completions_streaming(
|
||||
) -> Result<(String, Vec<ToolResult>)> {
|
||||
let (tx, rx) = unbounded_channel();
|
||||
let mut handler = SseHandler::new(tx, abort_signal.clone());
|
||||
let silent = ctx.render_mode == RenderMode::Silent;
|
||||
if silent {
|
||||
handler.set_silent(true);
|
||||
}
|
||||
|
||||
let (send_ret, render_ret) = tokio::join!(
|
||||
client.chat_completions_streaming(input, &mut handler),
|
||||
render_stream(rx, client.app_config(), abort_signal.clone()),
|
||||
render_stream(rx, client.app_config(), abort_signal.clone(), silent),
|
||||
);
|
||||
|
||||
if handler.abort().aborted() {
|
||||
|
||||
@@ -111,7 +111,7 @@ async fn prepare_chat_completions(
|
||||
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
|
||||
if !ready {
|
||||
bail!(
|
||||
"OAuth configured but no tokens found for '{}'. Run: 'loki --authenticate {}' or '.authenticate' in the REPL",
|
||||
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
|
||||
self_.name(),
|
||||
self_.name()
|
||||
);
|
||||
@@ -122,7 +122,7 @@ async fn prepare_chat_completions(
|
||||
request_data.header("x-goog-api-key", api_key);
|
||||
} else {
|
||||
bail!(
|
||||
"No authentication configured for '{}'. Set `api_key` or use `auth: oauth` with `loki --authenticate {}`.",
|
||||
"No authentication configured for '{}'. Set `api_key` or use `auth: oauth` with `coyote --authenticate {}`.",
|
||||
self_.name(),
|
||||
self_.name()
|
||||
);
|
||||
@@ -181,7 +181,7 @@ async fn prepare_embeddings(
|
||||
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
|
||||
if !ready {
|
||||
bail!(
|
||||
"OAuth configured but no tokens found for '{}'. Run: 'loki --authenticate {}' or '.authenticate' in the REPL",
|
||||
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
|
||||
self_.name(),
|
||||
self_.name()
|
||||
);
|
||||
|
||||
+10
-5
@@ -94,21 +94,21 @@ impl MessageContent {
|
||||
match self {
|
||||
MessageContent::Text(text) => multiline_text(text),
|
||||
MessageContent::Array(list) => {
|
||||
let (mut concated_text, mut files) = (String::new(), vec![]);
|
||||
let (mut concatenated_text, mut files) = (String::new(), vec![]);
|
||||
for item in list {
|
||||
match item {
|
||||
MessageContentPart::Text { text } => {
|
||||
concated_text = format!("{concated_text} {text}")
|
||||
concatenated_text = format!("{concatenated_text} {text}")
|
||||
}
|
||||
MessageContentPart::ImageUrl { image_url } => {
|
||||
files.push(resolve_url_fn(&image_url.url))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !concated_text.is_empty() {
|
||||
concated_text = format!(" -- {}", multiline_text(&concated_text))
|
||||
if !concatenated_text.is_empty() {
|
||||
concatenated_text = format!(" -- {}", multiline_text(&concatenated_text))
|
||||
}
|
||||
format!(".file {}{}", files.join(" "), concated_text)
|
||||
format!(".file {}{}", files.join(" "), concatenated_text)
|
||||
}
|
||||
MessageContent::ToolCalls(MessageContentToolCalls {
|
||||
tool_results, text, ..
|
||||
@@ -227,9 +227,14 @@ pub fn patch_messages(messages: &mut Vec<Message>, model: &Model) {
|
||||
}
|
||||
|
||||
pub fn extract_system_message(messages: &mut Vec<Message>) -> Option<String> {
|
||||
if messages.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if messages[0].role.is_system() {
|
||||
let system_message = messages.remove(0);
|
||||
return Some(system_message.content.to_text());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
+52
-32
@@ -2,9 +2,9 @@ use super::{ToolCall, catch_error};
|
||||
use crate::utils::AbortSignal;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use reqwest::RequestBuilder;
|
||||
use reqwest_eventsource::{Error as EventSourceError, Event, RequestBuilderExt};
|
||||
use reqwest::{RequestBuilder, header};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
@@ -16,6 +16,7 @@ pub struct SseHandler {
|
||||
last_tool_calls: Vec<ToolCall>,
|
||||
max_call_repeats: usize,
|
||||
call_repeat_chain_len: usize,
|
||||
silent: bool,
|
||||
}
|
||||
|
||||
impl SseHandler {
|
||||
@@ -28,14 +29,24 @@ impl SseHandler {
|
||||
last_tool_calls: Vec::new(),
|
||||
max_call_repeats: 2,
|
||||
call_repeat_chain_len: 3,
|
||||
silent: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_silent(&mut self, silent: bool) {
|
||||
self.silent = silent;
|
||||
}
|
||||
|
||||
pub fn text(&mut self, text: &str) -> Result<()> {
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.buffer.push_str(text);
|
||||
|
||||
if self.silent {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ret = self
|
||||
.sender
|
||||
.send(SseEvent::Text(text.to_string()))
|
||||
@@ -193,11 +204,46 @@ pub async fn sse_stream<F>(builder: RequestBuilder, mut handle: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(SseMessage) -> Result<bool>,
|
||||
{
|
||||
let mut es = builder.eventsource()?;
|
||||
let res = builder
|
||||
.header(header::ACCEPT, "text/event-stream")
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.send()
|
||||
.await?;
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
let text = res.text().await?;
|
||||
let data: Value = match text.parse() {
|
||||
Ok(data) => data,
|
||||
Err(_) => {
|
||||
bail!(
|
||||
"Invalid response data: {text} (status: {})",
|
||||
status.as_u16()
|
||||
);
|
||||
}
|
||||
};
|
||||
catch_error(&data, status.as_u16())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content_type = res
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_string());
|
||||
let is_event_stream = content_type
|
||||
.as_deref()
|
||||
.map(|ct| ct.starts_with("text/event-stream"))
|
||||
.unwrap_or(false);
|
||||
if !is_event_stream {
|
||||
let header_value = content_type.unwrap_or_default();
|
||||
let text = res.text().await?;
|
||||
bail!("Invalid response event-stream. content-type: {header_value}, data: {text}");
|
||||
}
|
||||
|
||||
let mut es = res.bytes_stream().boxed().eventsource();
|
||||
while let Some(event) = es.next().await {
|
||||
match event {
|
||||
Ok(Event::Open) => {}
|
||||
Ok(Event::Message(message)) => {
|
||||
Ok(message) => {
|
||||
let message = SseMessage {
|
||||
event: message.event,
|
||||
data: message.data,
|
||||
@@ -207,33 +253,7 @@ where
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
match err {
|
||||
EventSourceError::StreamEnded => {}
|
||||
EventSourceError::InvalidStatusCode(status, res) => {
|
||||
let text = res.text().await?;
|
||||
let data: Value = match text.parse() {
|
||||
Ok(data) => data,
|
||||
Err(_) => {
|
||||
bail!(
|
||||
"Invalid response data: {text} (status: {})",
|
||||
status.as_u16()
|
||||
);
|
||||
}
|
||||
};
|
||||
catch_error(&data, status.as_u16())?;
|
||||
}
|
||||
EventSourceError::InvalidContentType(header_value, res) => {
|
||||
let text = res.text().await?;
|
||||
bail!(
|
||||
"Invalid response event-stream. content-type: {}, data: {text}",
|
||||
header_value.to_str().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
bail!("{}", err);
|
||||
}
|
||||
}
|
||||
es.close();
|
||||
bail!("{err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+302
-55
@@ -11,6 +11,8 @@ use crate::config::prompts::{
|
||||
DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS, DEFAULT_TODO_INSTRUCTIONS,
|
||||
DEFAULT_USER_INTERACTION_INSTRUCTIONS,
|
||||
};
|
||||
use crate::graph::{Graph, GraphParser, NodeType};
|
||||
use crate::rag::RagInitConfig;
|
||||
use crate::vault::SECRET_RE;
|
||||
use anyhow::{Context, Result};
|
||||
use fancy_regex::Captures;
|
||||
@@ -37,12 +39,13 @@ pub struct Agent {
|
||||
session_dynamic_instructions: Option<String>,
|
||||
functions: Functions,
|
||||
rag: Option<Arc<Rag>>,
|
||||
graph_rags: HashMap<String, Arc<Rag>>,
|
||||
model: Model,
|
||||
vault: GlobalVault,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
pub fn install_builtin_agents() -> Result<()> {
|
||||
pub fn install_builtin_agents(force: bool) -> Result<()> {
|
||||
info!(
|
||||
"Installing built-in agents in {}",
|
||||
paths::agents_data_dir().display()
|
||||
@@ -62,7 +65,7 @@ impl Agent {
|
||||
#[cfg_attr(not(unix), expect(unused))]
|
||||
let is_script = matches!(file_extension.as_deref(), Some("sh") | Some("py"));
|
||||
|
||||
if file_path.exists() {
|
||||
if file_path.exists() && !force {
|
||||
debug!(
|
||||
"Agent file already exists, skipping: {}",
|
||||
file_path.display()
|
||||
@@ -97,10 +100,28 @@ impl Agent {
|
||||
let loaders = app.document_loaders.clone();
|
||||
let rag_path = paths::agent_rag_file(name, DEFAULT_AGENT_NAME);
|
||||
let config_path = paths::agent_config_file(name);
|
||||
let mut agent_config = if config_path.exists() {
|
||||
AgentConfig::load(&config_path)?
|
||||
} else {
|
||||
bail!("Agent config file not found at '{}'", config_path.display())
|
||||
let graph_path = paths::agent_graph_file(name);
|
||||
let mut graph_for_rag: Option<Graph> = None;
|
||||
let mut agent_config = match (config_path.exists(), graph_path.exists()) {
|
||||
(true, true) => bail!(
|
||||
"Agent '{name}' has both config.yaml and graph.yaml. A graph agent \
|
||||
is defined by graph.yaml alone; a normal agent by config.yaml alone. \
|
||||
Remove one of the two files."
|
||||
),
|
||||
(true, false) => AgentConfig::load(&config_path)?,
|
||||
(false, true) => {
|
||||
let parser = GraphParser::new(&agent_data_dir);
|
||||
let graph = parser
|
||||
.load_from_file(&graph_path)
|
||||
.with_context(|| format!("Failed to load graph.yaml for agent '{name}'"))?;
|
||||
let config = AgentConfig::from_graph(name, &graph);
|
||||
graph_for_rag = Some(graph);
|
||||
config
|
||||
}
|
||||
(false, false) => bail!(
|
||||
"Agent '{name}' has neither a config.yaml nor a graph.yaml at '{}'",
|
||||
agent_data_dir.display()
|
||||
),
|
||||
};
|
||||
let mut functions = Functions::init_agent(name, &agent_config.global_tools)?;
|
||||
|
||||
@@ -138,44 +159,16 @@ impl Agent {
|
||||
.prompt()?;
|
||||
}
|
||||
if ans {
|
||||
let mut document_paths = vec![];
|
||||
for path in &agent_config.documents {
|
||||
if is_url(path) {
|
||||
document_paths.push(path.to_string());
|
||||
} else if is_loader_protocol(&loaders, path) {
|
||||
let (protocol, document_path) = path
|
||||
.split_once(':')
|
||||
.with_context(|| "Invalid loader protocol path")?;
|
||||
let resolved_path = resolve_home_dir(document_path);
|
||||
let new_path = if Path::new(&resolved_path).is_relative() {
|
||||
safe_join_path(&agent_data_dir, resolved_path)
|
||||
.ok_or_else(|| anyhow!("Invalid document path: '{path}'"))?
|
||||
} else {
|
||||
PathBuf::from(&resolved_path)
|
||||
};
|
||||
document_paths.push(format!("{}:{}", protocol, new_path.display()));
|
||||
} else if Path::new(&resolve_home_dir(path)).is_relative() {
|
||||
let new_path = safe_join_path(&agent_data_dir, path)
|
||||
.ok_or_else(|| anyhow!("Invalid document path: '{path}'"))?;
|
||||
document_paths.push(new_path.display().to_string())
|
||||
} else {
|
||||
document_paths.push(path.to_string())
|
||||
}
|
||||
}
|
||||
let document_paths =
|
||||
resolve_document_paths(&agent_config.documents, &loaders, &agent_data_dir)?;
|
||||
let key = RagKey::Agent(name.to_string());
|
||||
let app_clone = app.clone();
|
||||
let rag_path_clone = rag_path.clone();
|
||||
let abort = abort_signal.clone();
|
||||
let rag = app_state
|
||||
.rag_cache
|
||||
.load_with(key, || async move {
|
||||
Rag::init(
|
||||
&app_clone,
|
||||
"rag",
|
||||
&rag_path_clone,
|
||||
&document_paths,
|
||||
abort_signal,
|
||||
)
|
||||
.await
|
||||
Rag::init(&app_clone, "rag", &rag_path_clone, &document_paths, abort).await
|
||||
})
|
||||
.await?;
|
||||
Some(rag)
|
||||
@@ -186,6 +179,23 @@ impl Agent {
|
||||
None
|
||||
};
|
||||
|
||||
let graph_rags = match &graph_for_rag {
|
||||
Some(graph) => {
|
||||
init_graph_rags(
|
||||
app,
|
||||
app_state,
|
||||
name,
|
||||
graph,
|
||||
&agent_data_dir,
|
||||
&loaders,
|
||||
info_flag,
|
||||
abort_signal.clone(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => HashMap::new(),
|
||||
};
|
||||
|
||||
if agent_config.auto_continue {
|
||||
functions.append_todo_functions();
|
||||
}
|
||||
@@ -208,6 +218,7 @@ impl Agent {
|
||||
session_dynamic_instructions: None,
|
||||
functions,
|
||||
rag,
|
||||
graph_rags,
|
||||
model,
|
||||
vault: app_state.vault.clone(),
|
||||
})
|
||||
@@ -287,10 +298,13 @@ impl Agent {
|
||||
.display()
|
||||
.to_string()
|
||||
.into();
|
||||
value["config_file"] = paths::agent_config_file(&self.name)
|
||||
.display()
|
||||
.to_string()
|
||||
.into();
|
||||
let config_path = paths::agent_config_file(&self.name);
|
||||
let definition_file = if config_path.exists() {
|
||||
config_path
|
||||
} else {
|
||||
paths::agent_graph_file(&self.name)
|
||||
};
|
||||
value["config_file"] = definition_file.display().to_string().into();
|
||||
let data = serde_yaml::to_string(&value)?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -311,6 +325,10 @@ impl Agent {
|
||||
self.rag.clone()
|
||||
}
|
||||
|
||||
pub fn graph_rag(&self, node_id: &str) -> Option<Arc<Rag>> {
|
||||
self.graph_rags.get(node_id).cloned()
|
||||
}
|
||||
|
||||
pub fn append_mcp_meta_functions(&mut self, mcp_servers: Vec<String>) {
|
||||
self.functions.append_mcp_meta_functions(mcp_servers);
|
||||
}
|
||||
@@ -415,6 +433,14 @@ impl Agent {
|
||||
self.config.max_auto_continues
|
||||
}
|
||||
|
||||
pub fn inject_todo_instructions(&self) -> bool {
|
||||
self.config.inject_todo_instructions
|
||||
}
|
||||
|
||||
pub fn continuation_prompt_value(&self) -> Option<String> {
|
||||
self.config.continuation_prompt.clone()
|
||||
}
|
||||
|
||||
pub fn can_spawn_agents(&self) -> bool {
|
||||
self.config.can_spawn_agents
|
||||
}
|
||||
@@ -439,18 +465,6 @@ impl Agent {
|
||||
self.config.escalation_timeout
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -512,7 +526,7 @@ impl RoleLike for Agent {
|
||||
}
|
||||
|
||||
fn enabled_tools(&self) -> Option<String> {
|
||||
self.config.global_tools.clone().join(",").into()
|
||||
None
|
||||
}
|
||||
|
||||
fn enabled_mcp_servers(&self) -> Option<String> {
|
||||
@@ -654,6 +668,25 @@ impl AgentConfig {
|
||||
Ok(agent_config)
|
||||
}
|
||||
|
||||
pub fn from_graph(dir_name: &str, graph: &Graph) -> Self {
|
||||
AgentConfig {
|
||||
name: dir_name.to_string(),
|
||||
model_id: graph.model.clone(),
|
||||
temperature: graph.temperature,
|
||||
top_p: graph.top_p,
|
||||
description: graph.description.clone(),
|
||||
global_tools: graph.global_tools.clone(),
|
||||
mcp_servers: graph.mcp_servers.clone(),
|
||||
conversation_starters: graph.conversation_starters.clone(),
|
||||
variables: graph.variables.clone(),
|
||||
can_spawn_agents: graph.has_agent_node(),
|
||||
max_concurrent_agents: default_max_concurrent_agents(),
|
||||
max_agent_depth: default_max_agent_depth(),
|
||||
escalation_timeout: default_escalation_timeout(),
|
||||
..AgentConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn load_envs(&mut self, app: &AppConfig) {
|
||||
let name = &self.name;
|
||||
let with_prefix = |v: &str| normalize_env_name(&format!("{name}_{v}"));
|
||||
@@ -750,6 +783,136 @@ pub struct AgentVariable {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
fn resolve_document_paths(
|
||||
documents: &[String],
|
||||
loaders: &HashMap<String, String>,
|
||||
agent_data_dir: &Path,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut document_paths = vec![];
|
||||
for path in documents {
|
||||
if is_url(path) {
|
||||
document_paths.push(path.to_string());
|
||||
} else if is_loader_protocol(loaders, path) {
|
||||
let (protocol, document_path) = path
|
||||
.split_once(':')
|
||||
.with_context(|| "Invalid loader protocol path")?;
|
||||
let resolved_path = resolve_home_dir(document_path);
|
||||
let new_path = if Path::new(&resolved_path).is_relative() {
|
||||
safe_join_path(agent_data_dir, resolved_path)
|
||||
.ok_or_else(|| anyhow!("Invalid document path: '{path}'"))?
|
||||
} else {
|
||||
PathBuf::from(&resolved_path)
|
||||
};
|
||||
|
||||
document_paths.push(format!("{}:{}", protocol, new_path.display()));
|
||||
} else if Path::new(&resolve_home_dir(path)).is_relative() {
|
||||
let new_path = safe_join_path(agent_data_dir, path)
|
||||
.ok_or_else(|| anyhow!("Invalid document path: '{path}'"))?;
|
||||
document_paths.push(new_path.display().to_string())
|
||||
} else {
|
||||
document_paths.push(path.to_string())
|
||||
}
|
||||
}
|
||||
Ok(document_paths)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn init_graph_rags(
|
||||
app: &AppConfig,
|
||||
app_state: &AppState,
|
||||
agent_name: &str,
|
||||
graph: &Graph,
|
||||
agent_data_dir: &Path,
|
||||
loaders: &HashMap<String, String>,
|
||||
info_flag: bool,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<HashMap<String, Arc<Rag>>> {
|
||||
let mut rags = HashMap::new();
|
||||
if info_flag {
|
||||
return Ok(rags);
|
||||
}
|
||||
|
||||
for (node_id, node) in &graph.nodes {
|
||||
let NodeType::Rag(rag_node) = &node.node_type else {
|
||||
continue;
|
||||
};
|
||||
let rag_path = paths::agent_rag_file(agent_name, node_id);
|
||||
let key = RagKey::GraphNode {
|
||||
agent: agent_name.to_string(),
|
||||
node: node_id.clone(),
|
||||
};
|
||||
let rag = if rag_path.exists() {
|
||||
let app_clone = app.clone();
|
||||
let path_clone = rag_path.clone();
|
||||
let name_clone = node_id.clone();
|
||||
app_state
|
||||
.rag_cache
|
||||
.load_with(key, || async move {
|
||||
Rag::load(&app_clone, &name_clone, &path_clone)
|
||||
})
|
||||
.await?
|
||||
} else {
|
||||
let config = RagInitConfig {
|
||||
embedding_model: rag_node.embedding_model.clone(),
|
||||
chunk_size: rag_node.chunk_size,
|
||||
chunk_overlap: rag_node.chunk_overlap,
|
||||
reranker_model: rag_node.reranker_model.clone(),
|
||||
top_k: rag_node.top_k,
|
||||
batch_size: rag_node.batch_size,
|
||||
};
|
||||
let fully_specified = config.embedding_model.is_some()
|
||||
&& config.chunk_size.is_some()
|
||||
&& config.chunk_overlap.is_some();
|
||||
if !fully_specified {
|
||||
if !*IS_STDOUT_TERMINAL {
|
||||
bail!(
|
||||
"Agent '{agent_name}' requires RAG for rag node '{node_id}', but its \
|
||||
knowledge base is not built and the node does not fully specify how \
|
||||
to build it. Set `embedding_model`, `chunk_size`, and `chunk_overlap` \
|
||||
on the node, or run the agent once interactively."
|
||||
);
|
||||
}
|
||||
|
||||
let ans = Confirm::new(&format!(
|
||||
"Initialize RAG knowledge base for rag node '{node_id}'?"
|
||||
))
|
||||
.with_default(true)
|
||||
.prompt()?;
|
||||
|
||||
if !ans {
|
||||
bail!(
|
||||
"Agent '{agent_name}' has rag node '{node_id}' but its RAG was not \
|
||||
initialized. RAG initialization is required for this agent."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let document_paths =
|
||||
resolve_document_paths(&rag_node.documents, loaders, agent_data_dir)?;
|
||||
let app_clone = app.clone();
|
||||
let path_clone = rag_path.clone();
|
||||
let name_clone = node_id.clone();
|
||||
let abort = abort_signal.clone();
|
||||
app_state
|
||||
.rag_cache
|
||||
.load_with(key, || async move {
|
||||
Rag::init_with_config(
|
||||
&app_clone,
|
||||
&name_clone,
|
||||
&path_clone,
|
||||
&document_paths,
|
||||
&config,
|
||||
abort,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await?
|
||||
};
|
||||
rags.insert(node_id.clone(), rag);
|
||||
}
|
||||
Ok(rags)
|
||||
}
|
||||
|
||||
pub fn list_agents() -> Vec<String> {
|
||||
let agents_data_dir = paths::agents_data_dir();
|
||||
if !agents_data_dir.exists() {
|
||||
@@ -876,4 +1039,88 @@ variables:
|
||||
assert!(config.inject_todo_instructions);
|
||||
assert!(config.inject_spawn_instructions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_graph_maps_agent_level_fields() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: graph_name_ignored
|
||||
description: A graph agent
|
||||
model: claude:claude-sonnet-4-6
|
||||
temperature: 0.3
|
||||
top_p: 0.8
|
||||
global_tools:
|
||||
- fetch_pdf.sh
|
||||
mcp_servers:
|
||||
- pubmed-search
|
||||
conversation_starters:
|
||||
- "Start here"
|
||||
start: e
|
||||
nodes:
|
||||
e:
|
||||
id: e
|
||||
type: end
|
||||
output: done
|
||||
"#};
|
||||
let graph: Graph = serde_yaml::from_str(&yaml).unwrap();
|
||||
|
||||
let config = AgentConfig::from_graph("my-agent-dir", &graph);
|
||||
|
||||
assert_eq!(config.name, "my-agent-dir");
|
||||
assert_eq!(config.description, "A graph agent");
|
||||
assert_eq!(config.model_id.as_deref(), Some("claude:claude-sonnet-4-6"));
|
||||
assert_eq!(config.temperature, Some(0.3));
|
||||
assert_eq!(config.top_p, Some(0.8));
|
||||
assert_eq!(config.global_tools, vec!["fetch_pdf.sh"]);
|
||||
assert_eq!(config.mcp_servers, vec!["pubmed-search"]);
|
||||
assert_eq!(config.conversation_starters, vec!["Start here"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_graph_derives_can_spawn_agents_from_agent_nodes() {
|
||||
let with_agent = formatdoc! {r#"
|
||||
name: g
|
||||
start: a
|
||||
nodes:
|
||||
a:
|
||||
id: a
|
||||
type: agent
|
||||
agent: helper
|
||||
prompt: hi
|
||||
next: e
|
||||
e:
|
||||
id: e
|
||||
type: end
|
||||
output: done
|
||||
"#};
|
||||
let graph: Graph = serde_yaml::from_str(&with_agent).unwrap();
|
||||
assert!(AgentConfig::from_graph("d", &graph).can_spawn_agents);
|
||||
|
||||
let no_agent =
|
||||
"name: g\nstart: x\nnodes:\n x:\n id: x\n type: end\n output: ok\n";
|
||||
let graph: Graph = serde_yaml::from_str(no_agent).unwrap();
|
||||
assert!(!AgentConfig::from_graph("d", &graph).can_spawn_agents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_graph_keeps_defaults_for_llm_loop_fields() {
|
||||
let yaml = "name: g\nstart: x\nnodes:\n x:\n id: x\n type: end\n output: ok\n";
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let config = AgentConfig::from_graph("d", &graph);
|
||||
|
||||
assert!(!config.auto_continue);
|
||||
assert!(config.instructions.is_empty());
|
||||
assert!(config.documents.is_empty());
|
||||
assert!(!config.inject_todo_instructions);
|
||||
assert!(!config.inject_spawn_instructions);
|
||||
assert_eq!(config.max_auto_continues, 0);
|
||||
assert_eq!(config.summarization_threshold, 0);
|
||||
|
||||
assert_eq!(
|
||||
config.max_concurrent_agents,
|
||||
default_max_concurrent_agents()
|
||||
);
|
||||
assert_eq!(config.max_agent_depth, default_max_agent_depth());
|
||||
assert_eq!(config.escalation_timeout, default_escalation_timeout());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@ pub struct AppConfig {
|
||||
pub mapping_mcp_servers: IndexMap<String, String>,
|
||||
pub enabled_mcp_servers: Option<String>,
|
||||
|
||||
pub auto_continue: bool,
|
||||
pub max_auto_continues: usize,
|
||||
pub inject_todo_instructions: bool,
|
||||
pub continuation_prompt: Option<String>,
|
||||
|
||||
pub repl_prelude: Option<String>,
|
||||
pub cmd_prelude: Option<String>,
|
||||
pub agent_session: Option<String>,
|
||||
@@ -95,6 +100,11 @@ impl Default for AppConfig {
|
||||
mapping_mcp_servers: Default::default(),
|
||||
enabled_mcp_servers: None,
|
||||
|
||||
auto_continue: false,
|
||||
max_auto_continues: 10,
|
||||
inject_todo_instructions: true,
|
||||
continuation_prompt: None,
|
||||
|
||||
repl_prelude: None,
|
||||
cmd_prelude: None,
|
||||
agent_session: None,
|
||||
@@ -152,6 +162,11 @@ impl AppConfig {
|
||||
mapping_mcp_servers: config.mapping_mcp_servers,
|
||||
enabled_mcp_servers: config.enabled_mcp_servers,
|
||||
|
||||
auto_continue: config.auto_continue,
|
||||
max_auto_continues: config.max_auto_continues,
|
||||
inject_todo_instructions: config.inject_todo_instructions,
|
||||
continuation_prompt: config.continuation_prompt,
|
||||
|
||||
repl_prelude: config.repl_prelude,
|
||||
cmd_prelude: config.cmd_prelude,
|
||||
agent_session: config.agent_session,
|
||||
|
||||
+2
-2
@@ -879,7 +879,7 @@ mod tests {
|
||||
#[test]
|
||||
fn from_files_loads_single_text_file() {
|
||||
let dir = env::temp_dir().join(format!(
|
||||
"loki-input-test-{}",
|
||||
"coyote-input-test-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
@@ -906,7 +906,7 @@ mod tests {
|
||||
#[test]
|
||||
fn from_files_loads_multiple_files() {
|
||||
let dir = env::temp_dir().join(format!(
|
||||
"loki-input-test-multi-{}",
|
||||
"coyote-input-test-multi-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,7 +85,7 @@ impl Macro {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn install_macros() -> Result<()> {
|
||||
pub fn install_macros(force: bool) -> Result<()> {
|
||||
info!(
|
||||
"Installing built-in macros in {}",
|
||||
paths::macros_dir().display()
|
||||
@@ -98,7 +98,7 @@ impl Macro {
|
||||
let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
|
||||
let file_path = paths::macros_dir().join(file.as_ref());
|
||||
|
||||
if file_path.exists() {
|
||||
if file_path.exists() && !force {
|
||||
debug!(
|
||||
"Macro file already exists, skipping: {}",
|
||||
file_path.display()
|
||||
|
||||
@@ -109,12 +109,13 @@ impl McpFactory {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mcp::{JsonField, McpServer, McpTransportType};
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn stdio_spec(
|
||||
command: &str,
|
||||
args: Option<Vec<String>>,
|
||||
env: Option<HashMap<String, JsonField>>,
|
||||
env: Option<IndexMap<String, JsonField>>,
|
||||
) -> McpServer {
|
||||
McpServer {
|
||||
transport_type: McpTransportType::Stdio,
|
||||
@@ -130,7 +131,7 @@ mod tests {
|
||||
fn remote_spec(
|
||||
transport: McpTransportType,
|
||||
url: &str,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
headers: Option<IndexMap<String, String>>,
|
||||
) -> McpServer {
|
||||
McpServer {
|
||||
transport_type: transport,
|
||||
@@ -145,7 +146,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn key_from_stdio_spec_captures_command_args_env() {
|
||||
let mut env = HashMap::new();
|
||||
let mut env = IndexMap::new();
|
||||
env.insert("TOKEN".into(), JsonField::Str("abc".into()));
|
||||
let spec = stdio_spec("npx", Some(vec!["-y".into(), "server".into()]), Some(env));
|
||||
let key = McpServerKey::from_spec("my-server", &spec);
|
||||
@@ -163,7 +164,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn key_from_stdio_spec_sorts_args_and_env() {
|
||||
let mut env = HashMap::new();
|
||||
let mut env = IndexMap::new();
|
||||
env.insert("Z_VAR".into(), JsonField::Str("z".into()));
|
||||
env.insert("A_VAR".into(), JsonField::Int(42));
|
||||
let spec = stdio_spec(
|
||||
@@ -222,7 +223,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn key_from_remote_sse_spec_with_sorted_headers() {
|
||||
let mut hdrs = HashMap::new();
|
||||
let mut hdrs = IndexMap::new();
|
||||
hdrs.insert("Z-Key".into(), "z-val".into());
|
||||
hdrs.insert("A-Key".into(), "a-val".into());
|
||||
let spec = remote_spec(McpTransportType::Sse, "http://sse.example.com", Some(hdrs));
|
||||
@@ -264,7 +265,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn key_env_bool_and_int_coerce_to_string() {
|
||||
let mut env = HashMap::new();
|
||||
let mut env = IndexMap::new();
|
||||
env.insert("FLAG".into(), JsonField::Bool(true));
|
||||
env.insert("PORT".into(), JsonField::Int(3000));
|
||||
let spec = stdio_spec("cmd", None, Some(env));
|
||||
|
||||
+154
-7
@@ -2,6 +2,7 @@ mod agent;
|
||||
mod app_config;
|
||||
mod app_state;
|
||||
mod input;
|
||||
mod install_remote;
|
||||
mod macros;
|
||||
mod mcp_factory;
|
||||
pub(crate) mod paths;
|
||||
@@ -12,19 +13,24 @@ mod role;
|
||||
mod session;
|
||||
pub(crate) mod todo;
|
||||
mod tool_scope;
|
||||
mod update;
|
||||
|
||||
pub use self::agent::{Agent, AgentVariables, complete_agent_variables, list_agents};
|
||||
pub use self::agent::{
|
||||
Agent, AgentVariable, AgentVariables, complete_agent_variables, list_agents,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::app_config::AppConfig;
|
||||
#[allow(unused_imports)]
|
||||
pub use self::app_state::AppState;
|
||||
pub use self::input::Input;
|
||||
pub use self::install_remote::{install_remote, install_remote_from_repl_args};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::request_context::RequestContext;
|
||||
pub use self::request_context::{RenderMode, RequestContext};
|
||||
pub use self::role::{
|
||||
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
|
||||
};
|
||||
use self::session::Session;
|
||||
pub use self::update::run_self_update;
|
||||
use crate::client::{
|
||||
ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
|
||||
ProviderModels, create_client_config, list_client_types,
|
||||
@@ -66,6 +72,7 @@ const DARK_THEME: &[u8] = include_bytes!("../../assets/monokai-extended.theme.bi
|
||||
const LIGHT_THEME: &[u8] = include_bytes!("../../assets/monokai-extended-light.theme.bin");
|
||||
|
||||
const CONFIG_FILE_NAME: &str = "config.yaml";
|
||||
const AGENT_GRAPH_FILE_NAME: &str = "graph.yaml";
|
||||
const ROLES_DIR_NAME: &str = "roles";
|
||||
const MACROS_DIR_NAME: &str = "macros";
|
||||
const ENV_FILE_NAME: &str = ".env";
|
||||
@@ -79,11 +86,31 @@ const GLOBAL_TOOLS_DIR_NAME: &str = "tools";
|
||||
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
|
||||
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
|
||||
const MCP_FILE_NAME: &str = "mcp.json";
|
||||
const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [
|
||||
"execute_command.sh",
|
||||
"execute_py_code.py",
|
||||
"execute_sql_code.sh",
|
||||
"fetch_url_via_curl.sh",
|
||||
"fs_cat.sh",
|
||||
"fs_glob.sh",
|
||||
"fs_grep.sh",
|
||||
"fs_ls.sh",
|
||||
"fs_mkdir.sh",
|
||||
"fs_patch.sh",
|
||||
"fs_read.sh",
|
||||
"fs_rm.sh",
|
||||
"fs_write.sh",
|
||||
"get_current_time.sh",
|
||||
"get_current_weather.sh",
|
||||
"search_wikipedia.sh",
|
||||
"search_arxiv.sh",
|
||||
"web_search_coyote.sh",
|
||||
];
|
||||
|
||||
const CLIENTS_FIELD: &str = "clients";
|
||||
|
||||
const SYNC_MODELS_URL: &str =
|
||||
"https://raw.githubusercontent.com/Dark-Alex-17/loki/refs/heads/main/models.yaml";
|
||||
"https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml";
|
||||
|
||||
const SUMMARIZATION_PROMPT: &str =
|
||||
"Summarize the discussion briefly in 200 words or less to use as a prompt for future context.";
|
||||
@@ -121,6 +148,11 @@ pub struct Config {
|
||||
pub mapping_mcp_servers: IndexMap<String, String>,
|
||||
pub enabled_mcp_servers: Option<String>,
|
||||
|
||||
pub auto_continue: bool,
|
||||
pub max_auto_continues: usize,
|
||||
pub inject_todo_instructions: bool,
|
||||
pub continuation_prompt: Option<String>,
|
||||
|
||||
pub repl_prelude: Option<String>,
|
||||
pub cmd_prelude: Option<String>,
|
||||
pub agent_session: Option<String>,
|
||||
@@ -177,6 +209,11 @@ impl Default for Config {
|
||||
mapping_mcp_servers: Default::default(),
|
||||
enabled_mcp_servers: None,
|
||||
|
||||
auto_continue: false,
|
||||
max_auto_continues: 10,
|
||||
inject_todo_instructions: true,
|
||||
continuation_prompt: None,
|
||||
|
||||
repl_prelude: None,
|
||||
cmd_prelude: None,
|
||||
agent_session: None,
|
||||
@@ -210,12 +247,110 @@ impl Default for Config {
|
||||
}
|
||||
|
||||
pub fn install_builtins() -> Result<()> {
|
||||
Functions::install_builtin_global_tools()?;
|
||||
Agent::install_builtin_agents()?;
|
||||
Macro::install_macros()?;
|
||||
Functions::install_builtin_global_tools(false)?;
|
||||
Agent::install_builtin_agents(false)?;
|
||||
Macro::install_macros(false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
|
||||
pub enum AssetCategory {
|
||||
Agents,
|
||||
Macros,
|
||||
Functions,
|
||||
#[value(name = "mcp_config")]
|
||||
McpConfig,
|
||||
}
|
||||
|
||||
impl AssetCategory {
|
||||
pub const NAMES: [&'static str; 4] = ["agents", "macros", "functions", "mcp_config"];
|
||||
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"agents" => Some(Self::Agents),
|
||||
"macros" => Some(Self::Macros),
|
||||
"functions" => Some(Self::Functions),
|
||||
"mcp_config" => Some(Self::McpConfig),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
|
||||
pub enum InstallFilter {
|
||||
Agents,
|
||||
Roles,
|
||||
Macros,
|
||||
Functions,
|
||||
#[value(name = "mcp_config")]
|
||||
McpConfig,
|
||||
}
|
||||
|
||||
impl InstallFilter {
|
||||
pub const NAMES: [&'static str; 5] = ["agents", "roles", "macros", "functions", "mcp_config"];
|
||||
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"agents" => Some(Self::Agents),
|
||||
"roles" => Some(Self::Roles),
|
||||
"macros" => Some(Self::Macros),
|
||||
"functions" => Some(Self::Functions),
|
||||
"mcp_config" => Some(Self::McpConfig),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install_assets(category: AssetCategory) -> Result<()> {
|
||||
let (label, target) = match category {
|
||||
AssetCategory::Agents => ("agents", paths::agents_data_dir()),
|
||||
AssetCategory::Macros => ("macros", paths::macros_dir()),
|
||||
AssetCategory::Functions => ("functions", paths::functions_dir()),
|
||||
AssetCategory::McpConfig => ("MCP config", paths::mcp_config_file()),
|
||||
};
|
||||
|
||||
if !confirm_asset_overwrite(category, label, &target)? {
|
||||
println!("Aborted. No files were changed.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match category {
|
||||
AssetCategory::Agents => Agent::install_builtin_agents(true)?,
|
||||
AssetCategory::Macros => Macro::install_macros(true)?,
|
||||
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
|
||||
AssetCategory::McpConfig => Functions::install_mcp_config()?,
|
||||
}
|
||||
|
||||
println!("Reinstalled bundled {label} ({})", target.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn confirm_asset_overwrite(category: AssetCategory, label: &str, target: &Path) -> Result<bool> {
|
||||
if !*IS_STDOUT_TERMINAL {
|
||||
return Ok(true);
|
||||
}
|
||||
let body = match category {
|
||||
AssetCategory::McpConfig => format!(
|
||||
"This replaces your MCP server configuration at {} with this \
|
||||
build's bundled template. Your configured MCP servers (and any \
|
||||
custom secret references they contain) will be lost.",
|
||||
target.display()
|
||||
),
|
||||
_ => format!(
|
||||
"Reinstalling bundled {label} overwrites every bundled {label} in \
|
||||
{} with this build's packaged versions. Local changes to bundled \
|
||||
{label} will be lost; {label} you created yourself are left \
|
||||
untouched.",
|
||||
target.display()
|
||||
),
|
||||
};
|
||||
let prompt = format!("{} {body}\nContinue? [y/N] ", warning_text("WARNING:"));
|
||||
let answer = read_single_key(&['y', 'Y', 'n', 'N'], 'n', &prompt)?;
|
||||
|
||||
Ok(matches!(answer, 'y' | 'Y'))
|
||||
}
|
||||
|
||||
pub fn default_sessions_dir() -> PathBuf {
|
||||
match env::var(get_env_name("sessions_dir")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
@@ -474,11 +609,23 @@ pub async fn create_config_file(config_path: &Path) -> Result<()> {
|
||||
let (model, clients_config) = create_client_config(client, &vault).await?;
|
||||
config["model"] = model.into();
|
||||
config["vault_password_file"] = vault.password_file()?.display().to_string().into();
|
||||
config["stream"] = json!(true);
|
||||
config["save"] = json!(true);
|
||||
config["keybindings"] = json!("vi");
|
||||
config["wrap"] = json!("auto");
|
||||
config["wrap_code"] = json!(false);
|
||||
config["function_calling_support"] = json!(true);
|
||||
config["enabled_tools"] = json!(null);
|
||||
config["visible_tools"] = json!(DEFAULT_VISIBLE_TOOLS);
|
||||
config["mcp_server_support"] = json!(true);
|
||||
config["enabled_mcp_servers"] = json!(null);
|
||||
config["highlight"] = json!(true);
|
||||
config["light_theme"] = json!(false);
|
||||
config[CLIENTS_FIELD] = clients_config;
|
||||
|
||||
let config_data = serde_yaml::to_string(&config).with_context(|| "Failed to create config")?;
|
||||
let config_data = format!(
|
||||
"# see https://github.com/Dark-Alex-17/loki/blob/main/config.example.yaml\n\n{config_data}"
|
||||
"# see https://github.com/Dark-Alex-17/coyote/blob/main/config.example.yaml\n\n{config_data}"
|
||||
);
|
||||
|
||||
ensure_parent_exists(config_path)?;
|
||||
|
||||
+8
-3
@@ -1,8 +1,9 @@
|
||||
use super::role::Role;
|
||||
use super::{
|
||||
AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME, ENV_FILE_NAME,
|
||||
FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME, GLOBAL_TOOLS_UTILS_DIR_NAME,
|
||||
MACROS_DIR_NAME, MCP_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME,
|
||||
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME,
|
||||
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME,
|
||||
GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, ModelsOverride, RAGS_DIR_NAME,
|
||||
ROLES_DIR_NAME,
|
||||
};
|
||||
use crate::client::ProviderModels;
|
||||
use crate::utils::{get_env_name, list_file_names, normalize_env_name};
|
||||
@@ -127,6 +128,10 @@ pub fn agent_data_dir(name: &str) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn agent_graph_file(agent_name: &str) -> PathBuf {
|
||||
agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME)
|
||||
}
|
||||
|
||||
pub fn agent_config_file(name: &str) -> PathBuf {
|
||||
match env::var(format!("{}_CONFIG_FILE", normalize_env_name(name))) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::sync::{Arc, Weak};
|
||||
pub enum RagKey {
|
||||
Named(String),
|
||||
Agent(String),
|
||||
GraphNode { agent: String, node: String },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
||||
+666
-83
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,14 @@ pub struct Role {
|
||||
enabled_tools: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled_mcp_servers: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
auto_continue: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_auto_continues: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
inject_todo_instructions: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
continuation_prompt: Option<String>,
|
||||
|
||||
#[serde(skip)]
|
||||
model: Model,
|
||||
@@ -90,6 +98,14 @@ impl Role {
|
||||
"enabled_mcp_servers" => {
|
||||
role.enabled_mcp_servers = value.as_str().map(|v| v.to_string())
|
||||
}
|
||||
"auto_continue" => role.auto_continue = value.as_bool(),
|
||||
"max_auto_continues" => {
|
||||
role.max_auto_continues = value.as_u64().map(|v| v as usize)
|
||||
}
|
||||
"inject_todo_instructions" => role.inject_todo_instructions = value.as_bool(),
|
||||
"continuation_prompt" => {
|
||||
role.continuation_prompt = value.as_str().map(|v| v.to_string())
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
@@ -131,6 +147,20 @@ impl Role {
|
||||
if let Some(enabled_mcp_servers) = self.enabled_mcp_servers() {
|
||||
metadata.push(format!("enabled_mcp_servers: {enabled_mcp_servers}"));
|
||||
}
|
||||
if let Some(auto_continue) = self.auto_continue {
|
||||
metadata.push(format!("auto_continue: {auto_continue}"));
|
||||
}
|
||||
if let Some(max_auto_continues) = self.max_auto_continues {
|
||||
metadata.push(format!("max_auto_continues: {max_auto_continues}"));
|
||||
}
|
||||
if let Some(inject_todo_instructions) = self.inject_todo_instructions {
|
||||
metadata.push(format!(
|
||||
"inject_todo_instructions: {inject_todo_instructions}"
|
||||
));
|
||||
}
|
||||
if let Some(continuation_prompt) = &self.continuation_prompt {
|
||||
metadata.push(format!("continuation_prompt: {continuation_prompt}"));
|
||||
}
|
||||
if metadata.is_empty() {
|
||||
format!("{}\n", self.prompt)
|
||||
} else if self.prompt.is_empty() {
|
||||
@@ -225,6 +255,26 @@ impl Role {
|
||||
self.prompt.contains(INPUT_PLACEHOLDER)
|
||||
}
|
||||
|
||||
pub fn auto_continue(&self) -> Option<bool> {
|
||||
self.auto_continue
|
||||
}
|
||||
|
||||
pub fn max_auto_continues(&self) -> Option<usize> {
|
||||
self.max_auto_continues
|
||||
}
|
||||
|
||||
pub fn inject_todo_instructions(&self) -> Option<bool> {
|
||||
self.inject_todo_instructions
|
||||
}
|
||||
|
||||
pub fn continuation_prompt(&self) -> Option<&str> {
|
||||
self.continuation_prompt.as_deref()
|
||||
}
|
||||
|
||||
pub fn append_to_prompt(&mut self, text: &str) {
|
||||
self.prompt.push_str(text);
|
||||
}
|
||||
|
||||
pub fn echo_messages(&self, input: &Input) -> String {
|
||||
let input_markdown = input.render();
|
||||
if self.is_empty_prompt() {
|
||||
|
||||
+80
-9
@@ -32,6 +32,14 @@ pub struct Session {
|
||||
save_session: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
compression_threshold: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
auto_continue: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_auto_continues: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
inject_todo_instructions: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
continuation_prompt: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
role_name: Option<String>,
|
||||
@@ -170,6 +178,18 @@ impl Session {
|
||||
if let Some(save_session) = self.save_session() {
|
||||
data["save_session"] = save_session.into();
|
||||
}
|
||||
if let Some(auto_continue) = self.auto_continue() {
|
||||
data["auto_continue"] = auto_continue.into();
|
||||
}
|
||||
if let Some(max_auto_continues) = self.max_auto_continues() {
|
||||
data["max_auto_continues"] = max_auto_continues.into();
|
||||
}
|
||||
if let Some(inject_todo_instructions) = self.inject_todo_instructions() {
|
||||
data["inject_todo_instructions"] = inject_todo_instructions.into();
|
||||
}
|
||||
if let Some(continuation_prompt) = self.continuation_prompt() {
|
||||
data["continuation_prompt"] = continuation_prompt.into();
|
||||
}
|
||||
let (tokens, percent) = self.tokens_usage();
|
||||
data["total_tokens"] = tokens.into();
|
||||
if let Some(max_input_tokens) = self.model().max_input_tokens() {
|
||||
@@ -225,6 +245,22 @@ impl Session {
|
||||
items.push(("compression_threshold", compression_threshold.to_string()));
|
||||
}
|
||||
|
||||
if let Some(auto_continue) = self.auto_continue() {
|
||||
items.push(("auto_continue", auto_continue.to_string()));
|
||||
}
|
||||
if let Some(max_auto_continues) = self.max_auto_continues() {
|
||||
items.push(("max_auto_continues", max_auto_continues.to_string()));
|
||||
}
|
||||
if let Some(inject_todo_instructions) = self.inject_todo_instructions() {
|
||||
items.push((
|
||||
"inject_todo_instructions",
|
||||
inject_todo_instructions.to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(continuation_prompt) = self.continuation_prompt() {
|
||||
items.push(("continuation_prompt", continuation_prompt.to_string()));
|
||||
}
|
||||
|
||||
if let Some(max_input_tokens) = self.model().max_input_tokens() {
|
||||
items.push(("max_input_tokens", max_input_tokens.to_string()));
|
||||
}
|
||||
@@ -335,6 +371,50 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auto_continue(&self) -> Option<bool> {
|
||||
self.auto_continue
|
||||
}
|
||||
|
||||
pub fn max_auto_continues(&self) -> Option<usize> {
|
||||
self.max_auto_continues
|
||||
}
|
||||
|
||||
pub fn set_auto_continue(&mut self, value: Option<bool>) {
|
||||
if self.auto_continue != value {
|
||||
self.auto_continue = value;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_max_auto_continues(&mut self, value: Option<usize>) {
|
||||
if self.max_auto_continues != value {
|
||||
self.max_auto_continues = value;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inject_todo_instructions(&self) -> Option<bool> {
|
||||
self.inject_todo_instructions
|
||||
}
|
||||
|
||||
pub fn continuation_prompt(&self) -> Option<&str> {
|
||||
self.continuation_prompt.as_deref()
|
||||
}
|
||||
|
||||
pub fn set_inject_todo_instructions(&mut self, value: Option<bool>) {
|
||||
if self.inject_todo_instructions != value {
|
||||
self.inject_todo_instructions = value;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_continuation_prompt(&mut self, value: Option<String>) {
|
||||
if self.continuation_prompt != value {
|
||||
self.continuation_prompt = value;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn needs_compression(&self, global_compression_threshold: usize) -> bool {
|
||||
if self.compressing {
|
||||
return false;
|
||||
@@ -548,15 +628,6 @@ impl Session {
|
||||
let mut messages = self.messages.clone();
|
||||
if input.continue_output().is_some() {
|
||||
return messages;
|
||||
} else if input.regenerate() {
|
||||
while let Some(last) = messages.last() {
|
||||
if !last.role.is_user() {
|
||||
messages.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
let mut need_add_msg = true;
|
||||
let len = messages.len();
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToolScope {
|
||||
pub functions: Functions,
|
||||
pub mcp_runtime: McpRuntime,
|
||||
@@ -24,7 +25,7 @@ impl Default for ToolScope {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Default, Clone)]
|
||||
pub struct McpRuntime {
|
||||
pub servers: HashMap<String, Arc<ConnectedServer>>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
use crate::utils::warning_text;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use dunce::canonicalize;
|
||||
use inquire::Confirm;
|
||||
use is_terminal::IsTerminal;
|
||||
use self_update::Status;
|
||||
use self_update::backends::github::Update;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::Path;
|
||||
use std::{env, fs, io, process};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum InstallSource {
|
||||
Cargo,
|
||||
Homebrew,
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl InstallSource {
|
||||
fn is_package_managed(self) -> bool {
|
||||
matches!(self, InstallSource::Cargo | InstallSource::Homebrew)
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
InstallSource::Cargo => "Cargo",
|
||||
InstallSource::Homebrew => "Homebrew",
|
||||
InstallSource::Manual => "manually-installed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_install_path(path: &Path) -> InstallSource {
|
||||
let components: Vec<&str> = path
|
||||
.components()
|
||||
.filter_map(|c| c.as_os_str().to_str())
|
||||
.collect();
|
||||
|
||||
if components
|
||||
.windows(2)
|
||||
.any(|w| w[0] == ".cargo" && w[1] == "bin")
|
||||
{
|
||||
return InstallSource::Cargo;
|
||||
}
|
||||
|
||||
if components.contains(&"Cellar") {
|
||||
return InstallSource::Homebrew;
|
||||
}
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str.starts_with("/opt/homebrew/") || path_str.starts_with("/home/linuxbrew/.linuxbrew/")
|
||||
{
|
||||
return InstallSource::Homebrew;
|
||||
}
|
||||
|
||||
InstallSource::Manual
|
||||
}
|
||||
|
||||
fn normalize_version(requested: Option<String>) -> Option<String> {
|
||||
let raw = requested?;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("latest") {
|
||||
return None;
|
||||
}
|
||||
match trimmed.chars().next() {
|
||||
Some('v' | 'V') => Some(trimmed.to_string()),
|
||||
Some(c) if c.is_ascii_digit() => Some(format!("v{trimmed}")),
|
||||
_ => Some(trimmed.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_dir_writable(dir: &Path) -> bool {
|
||||
let probe = dir.join(format!(".coyote-update-write-test-{}", process::id()));
|
||||
match OpenOptions::new().write(true).create_new(true).open(&probe) {
|
||||
Ok(_) => {
|
||||
let _ = fs::remove_file(&probe);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_self_update(requested: Option<String>, force: bool) -> Result<()> {
|
||||
let target_tag = normalize_version(requested);
|
||||
|
||||
let exe_path = env::current_exe()
|
||||
.context("Could not determine the path of the running coyote executable")?;
|
||||
let resolved = canonicalize(&exe_path).unwrap_or_else(|_| exe_path.clone());
|
||||
let source = classify_install_path(&resolved);
|
||||
|
||||
if source.is_package_managed() {
|
||||
let body = match source {
|
||||
InstallSource::Homebrew => format!(
|
||||
"Coyote appears to be installed via Homebrew ({}).\n\
|
||||
Updating in place replaces the binary inside Homebrew's Cellar; `brew` will\n\
|
||||
then report a version that no longer matches the file on disk, and a later\n\
|
||||
`brew upgrade`/`brew reinstall` may overwrite it or fail.\n\
|
||||
The clean way to update is: brew upgrade coyote",
|
||||
exe_path.display()
|
||||
),
|
||||
InstallSource::Cargo => format!(
|
||||
"Coyote appears to be installed via `cargo install` ({}).\n\
|
||||
Updating in place leaves Cargo's records out of sync with the binary on disk.\n\
|
||||
The clean way to update is: cargo install --locked coyote-ai",
|
||||
exe_path.display()
|
||||
),
|
||||
InstallSource::Manual => unreachable!("Manual installs are not package-managed"),
|
||||
};
|
||||
println!("{} {body}", warning_text("WARNING:"));
|
||||
|
||||
if force {
|
||||
println!("--force specified; updating anyway.");
|
||||
} else if io::stdin().is_terminal() {
|
||||
let proceed = Confirm::new("Update anyway?")
|
||||
.with_default(false)
|
||||
.prompt()?;
|
||||
if !proceed {
|
||||
println!("Update cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
bail!(
|
||||
"Refusing to update a {} install. Re-run with --force to override.",
|
||||
source.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = exe_path.parent()
|
||||
&& !is_dir_writable(parent)
|
||||
{
|
||||
bail!(
|
||||
"No write permission for '{}'. Re-run with elevated permissions (e.g. sudo), \
|
||||
or update Coyote through your package manager.",
|
||||
parent.display()
|
||||
);
|
||||
}
|
||||
|
||||
let interactive = io::stdin().is_terminal();
|
||||
let mut builder = Update::configure();
|
||||
builder
|
||||
.repo_owner("Dark-Alex-17")
|
||||
.repo_name("coyote")
|
||||
.bin_name("coyote")
|
||||
.current_version(env!("CARGO_PKG_VERSION"))
|
||||
.no_confirm(true)
|
||||
.show_download_progress(interactive);
|
||||
if let Some(tag) = &target_tag {
|
||||
builder.target_version_tag(tag.as_str());
|
||||
}
|
||||
let status = builder
|
||||
.build()
|
||||
.context("Failed to configure the self-update")?
|
||||
.update()
|
||||
.context("Self-update failed")?;
|
||||
|
||||
match status {
|
||||
Status::UpToDate(version) => {
|
||||
println!("Coyote is already up to date (v{version}).");
|
||||
}
|
||||
Status::Updated(version) => {
|
||||
println!("Coyote updated to v{version}. Restart coyote to use the new version.");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn classify_cargo_install() {
|
||||
assert_eq!(
|
||||
classify_install_path(&PathBuf::from("/home/u/.cargo/bin/coyote")),
|
||||
InstallSource::Cargo
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_homebrew_opt_prefix() {
|
||||
assert_eq!(
|
||||
classify_install_path(&PathBuf::from("/opt/homebrew/bin/coyote")),
|
||||
InstallSource::Homebrew
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_homebrew_cellar() {
|
||||
assert_eq!(
|
||||
classify_install_path(&PathBuf::from("/usr/local/Cellar/coyote/0.3.0/bin/coyote")),
|
||||
InstallSource::Homebrew
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_homebrew_linuxbrew() {
|
||||
assert_eq!(
|
||||
classify_install_path(&PathBuf::from("/home/linuxbrew/.linuxbrew/bin/coyote")),
|
||||
InstallSource::Homebrew
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_manual_usr_local_bin() {
|
||||
assert_eq!(
|
||||
classify_install_path(&PathBuf::from("/usr/local/bin/coyote")),
|
||||
InstallSource::Manual
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_manual_local_bin() {
|
||||
assert_eq!(
|
||||
classify_install_path(&PathBuf::from("/home/u/.local/bin/coyote")),
|
||||
InstallSource::Manual
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_version_latest_and_empty_are_none() {
|
||||
assert_eq!(normalize_version(None), None);
|
||||
assert_eq!(normalize_version(Some(String::new())), None);
|
||||
assert_eq!(normalize_version(Some(" ".to_string())), None);
|
||||
assert_eq!(normalize_version(Some("latest".to_string())), None);
|
||||
assert_eq!(normalize_version(Some("LATEST".to_string())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_version_prepends_v_for_bare_semver() {
|
||||
assert_eq!(
|
||||
normalize_version(Some("0.4.0".to_string())),
|
||||
Some("v0.4.0".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_version(Some("v0.4.0".to_string())),
|
||||
Some("v0.4.0".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_version(Some(" v0.4.0 ".to_string())),
|
||||
Some("v0.4.0".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
+85
-13
@@ -4,6 +4,7 @@ pub(crate) mod user_interaction;
|
||||
|
||||
use crate::{
|
||||
config::{Agent, RequestContext},
|
||||
graph,
|
||||
utils::*,
|
||||
};
|
||||
|
||||
@@ -51,7 +52,7 @@ enum BinaryType<'a> {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsRefStr)]
|
||||
enum Language {
|
||||
pub enum Language {
|
||||
Bash,
|
||||
Python,
|
||||
TypeScript,
|
||||
@@ -60,7 +61,13 @@ enum Language {
|
||||
|
||||
impl From<&String> for Language {
|
||||
fn from(s: &String) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
Language::from_extension(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub fn from_extension(ext: &str) -> Self {
|
||||
match ext.to_lowercase().as_str() {
|
||||
"sh" => Language::Bash,
|
||||
"py" => Language::Python,
|
||||
"ts" => Language::TypeScript,
|
||||
@@ -90,6 +97,17 @@ impl Language {
|
||||
}
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub fn direct_invoker(self) -> Option<(&'static str, &'static [&'static str])> {
|
||||
match self {
|
||||
Language::Bash => Some(("bash", &[])),
|
||||
Language::Python => Some(("python3", &[])),
|
||||
Language::TypeScript => Some(("npx", &["tsx"])),
|
||||
Language::Unsupported => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_shebang_runtime(path: &Path) -> Option<String> {
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = io::BufReader::new(file);
|
||||
@@ -192,7 +210,7 @@ pub struct Functions {
|
||||
}
|
||||
|
||||
impl Functions {
|
||||
pub fn install_builtin_global_tools() -> Result<()> {
|
||||
pub fn install_builtin_global_tools(force: bool) -> Result<()> {
|
||||
info!(
|
||||
"Installing global built-in functions in {}",
|
||||
paths::functions_dir().display()
|
||||
@@ -210,14 +228,14 @@ impl Functions {
|
||||
})?;
|
||||
let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
|
||||
let file_path = paths::functions_dir().join(file.as_ref());
|
||||
let file_extension = file_path
|
||||
#[cfg_attr(not(unix), expect(unused))]
|
||||
let is_script = file_path
|
||||
.extension()
|
||||
.and_then(OsStr::to_str)
|
||||
.map(|s| s.to_lowercase());
|
||||
#[cfg_attr(not(unix), expect(unused))]
|
||||
let is_script = matches!(file_extension.as_deref(), Some("sh") | Some("py"));
|
||||
.is_some_and(|ext| Language::from_extension(ext) != Language::Unsupported);
|
||||
|
||||
if file_path.exists() {
|
||||
let force_this = force && file.as_ref() != "mcp.json";
|
||||
if file_path.exists() && !force_this {
|
||||
debug!(
|
||||
"Function file already exists, skipping: {}",
|
||||
file_path.display()
|
||||
@@ -240,6 +258,22 @@ impl Functions {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn install_mcp_config() -> Result<()> {
|
||||
let file_path = paths::mcp_config_file();
|
||||
let embedded = FunctionAssets::get("mcp.json")
|
||||
.ok_or_else(|| anyhow!("Failed to load embedded mcp.json"))?;
|
||||
let content = unsafe { std::str::from_utf8_unchecked(&embedded.data) };
|
||||
|
||||
ensure_parent_exists(&file_path)?;
|
||||
|
||||
info!("Reinstalling MCP config file: {}", file_path.display());
|
||||
|
||||
let mut config_file = File::create(&file_path)?;
|
||||
config_file.write_all(content.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn init(visible_tools: &[String]) -> Result<Self> {
|
||||
Self::clear_global_functions_bin_dir()?;
|
||||
|
||||
@@ -1166,6 +1200,9 @@ pub fn run_llm_function(
|
||||
if dir.exists() {
|
||||
bin_dirs.push(dir);
|
||||
}
|
||||
if graph::agent_has_graph(&agent_name) {
|
||||
envs.insert("AUTO_CONFIRM".into(), "true".into());
|
||||
}
|
||||
} else {
|
||||
bin_dirs.push(paths::functions_bin_dir());
|
||||
}
|
||||
@@ -1209,7 +1246,7 @@ pub fn run_llm_function(
|
||||
.map_err(|err| anyhow!("Unable to run {command_name}, {err}"))?;
|
||||
|
||||
let stdout = child.stdout.take().expect("Failed to capture stdout");
|
||||
let mut stderr = child.stderr.take().expect("Failed to capture stderr");
|
||||
let stderr = child.stderr.take().expect("Failed to capture stderr");
|
||||
|
||||
let stdout_thread = std::thread::spawn(move || {
|
||||
let mut buffer = [0; 1024];
|
||||
@@ -1236,8 +1273,29 @@ pub fn run_llm_function(
|
||||
});
|
||||
|
||||
let stderr_thread = std::thread::spawn(move || {
|
||||
let mut buffer = [0; 1024];
|
||||
let mut reader = stderr;
|
||||
let mut err = io::stderr();
|
||||
let mut buf = Vec::new();
|
||||
let _ = stderr.read_to_end(&mut buf);
|
||||
while let Ok(n) = reader.read(&mut buffer) {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let chunk = &buffer[0..n];
|
||||
buf.extend_from_slice(chunk);
|
||||
let mut last_pos = 0;
|
||||
for (i, &byte) in chunk.iter().enumerate() {
|
||||
if byte == b'\n' {
|
||||
let _ = err.write_all(&chunk[last_pos..i]);
|
||||
let _ = err.write_all(b"\r\n");
|
||||
last_pos = i + 1;
|
||||
}
|
||||
}
|
||||
if last_pos < n {
|
||||
let _ = err.write_all(&chunk[last_pos..n]);
|
||||
}
|
||||
let _ = err.flush();
|
||||
}
|
||||
buf
|
||||
});
|
||||
|
||||
@@ -1250,9 +1308,6 @@ pub fn run_llm_function(
|
||||
let exit_code = status.code().unwrap_or_default();
|
||||
if exit_code != 0 {
|
||||
let stderr = String::from_utf8_lossy(&stderr_bytes).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});
|
||||
@@ -1415,6 +1470,23 @@ mod tests {
|
||||
assert!(tc.thought_signature.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_invoker_maps_each_language() {
|
||||
assert_eq!(
|
||||
Language::Bash.direct_invoker(),
|
||||
Some(("bash", &[] as &[&str]))
|
||||
);
|
||||
assert_eq!(
|
||||
Language::Python.direct_invoker(),
|
||||
Some(("python3", &[] as &[&str]))
|
||||
);
|
||||
assert_eq!(
|
||||
Language::TypeScript.direct_invoker(),
|
||||
Some(("npx", &["tsx"] as &[&str]))
|
||||
);
|
||||
assert_eq!(Language::Unsupported.direct_invoker(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolcall_with_thought_signature() {
|
||||
let tc = ToolCall::new("t".into(), json!({}), None)
|
||||
|
||||
+167
-13
@@ -5,6 +5,7 @@ use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox};
|
||||
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor};
|
||||
use crate::utils::{AbortSignal, create_abort_signal};
|
||||
|
||||
use crate::graph;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use chrono::Utc;
|
||||
use indexmap::IndexMap;
|
||||
@@ -13,6 +14,8 @@ use parking_lot::RwLock;
|
||||
use serde_json::{Value, json};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const SUPERVISOR_FUNCTION_PREFIX: &str = "agent__";
|
||||
@@ -324,12 +327,21 @@ pub async fn handle_supervisor_tool(
|
||||
}
|
||||
}
|
||||
|
||||
fn run_child_agent(
|
||||
pub fn run_child_agent(
|
||||
mut child_ctx: RequestContext,
|
||||
initial_input: Input,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String>> + Send>> {
|
||||
Box::pin(async move {
|
||||
if graph::active_agent_graph_name(&child_ctx).is_some() {
|
||||
return graph::run_active_agent_graph(
|
||||
&mut child_ctx,
|
||||
&initial_input.text(),
|
||||
abort_signal,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut accumulated_output = String::new();
|
||||
let mut input = initial_input;
|
||||
let app = Arc::clone(&child_ctx.app.config);
|
||||
@@ -372,6 +384,98 @@ fn run_child_agent(
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn an agent synchronously from a graph node and return its accumulated
|
||||
/// output. This is similar to `handle_spawn` but runs the child agent in the
|
||||
/// current task (no tokio::spawn, no supervisor handle registration) so the
|
||||
/// graph executor can sequence agent nodes directly.
|
||||
pub async fn run_agent_for_graph(
|
||||
parent_ctx: &mut RequestContext,
|
||||
agent_name: &str,
|
||||
prompt: &str,
|
||||
) -> Result<String> {
|
||||
let short_uuid = &Uuid::new_v4().to_string()[..8];
|
||||
let agent_id = format!("graph_agent_{agent_name}_{short_uuid}");
|
||||
let current_depth = parent_ctx.current_depth + 1;
|
||||
|
||||
if let Some(supervisor) = parent_ctx.supervisor.as_ref().cloned() {
|
||||
let max_depth = supervisor.read().max_depth();
|
||||
if current_depth > max_depth {
|
||||
bail!("Max agent depth exceeded ({current_depth}/{max_depth})");
|
||||
}
|
||||
}
|
||||
|
||||
if !parent_ctx.app.config.function_calling_support {
|
||||
bail!("Function calling support must be enabled to spawn agents.");
|
||||
}
|
||||
|
||||
let child_inbox = Arc::new(Inbox::new());
|
||||
parent_ctx.ensure_root_escalation_queue();
|
||||
let child_abort = create_abort_signal();
|
||||
|
||||
let app_config = Arc::clone(&parent_ctx.app.config);
|
||||
let current_model = parent_ctx.current_model().clone();
|
||||
let info_flag = parent_ctx.info_flag;
|
||||
let child_app_state = Arc::new(AppState {
|
||||
config: Arc::new(app_config.as_ref().clone()),
|
||||
vault: parent_ctx.app.vault.clone(),
|
||||
mcp_factory: parent_ctx.app.mcp_factory.clone(),
|
||||
rag_cache: parent_ctx.app.rag_cache.clone(),
|
||||
mcp_config: parent_ctx.app.mcp_config.clone(),
|
||||
mcp_log_path: parent_ctx.app.mcp_log_path.clone(),
|
||||
mcp_registry: parent_ctx.app.mcp_registry.clone(),
|
||||
functions: parent_ctx.app.functions.clone(),
|
||||
});
|
||||
|
||||
let agent = Agent::init(
|
||||
app_config.as_ref(),
|
||||
child_app_state.as_ref(),
|
||||
¤t_model,
|
||||
info_flag,
|
||||
agent_name,
|
||||
child_abort.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let agent_mcp_servers = agent.mcp_server_names().to_vec();
|
||||
let session = agent.agent_session().map(|v| v.to_string());
|
||||
let should_init_supervisor = agent.can_spawn_agents();
|
||||
let agent_max_concurrent = agent.max_concurrent_agents();
|
||||
let agent_max_depth = agent.max_agent_depth();
|
||||
|
||||
let mut child_ctx = RequestContext::new_for_child(
|
||||
Arc::clone(&child_app_state),
|
||||
parent_ctx,
|
||||
current_depth,
|
||||
Arc::clone(&child_inbox),
|
||||
agent_id.clone(),
|
||||
);
|
||||
child_ctx.rag = agent.rag();
|
||||
child_ctx.agent = Some(agent);
|
||||
if should_init_supervisor {
|
||||
child_ctx.supervisor = Some(Arc::new(RwLock::new(Supervisor::new(
|
||||
agent_max_concurrent,
|
||||
agent_max_depth,
|
||||
))));
|
||||
}
|
||||
|
||||
if let Some(session) = session {
|
||||
child_ctx
|
||||
.use_session(app_config.as_ref(), Some(&session), child_abort.clone())
|
||||
.await?;
|
||||
sync_agent_functions_to_ctx(&mut child_ctx)?;
|
||||
} else {
|
||||
populate_agent_mcp_runtime(&mut child_ctx, &agent_mcp_servers).await?;
|
||||
sync_agent_functions_to_ctx(&mut child_ctx)?;
|
||||
child_ctx.init_agent_shared_variables()?;
|
||||
}
|
||||
|
||||
let input = Input::from_str(&child_ctx, prompt, None);
|
||||
|
||||
debug!("Spawning agent '{agent_name}' for graph node as '{agent_id}'");
|
||||
|
||||
run_child_agent(child_ctx, input, child_abort).await
|
||||
}
|
||||
|
||||
async fn populate_agent_mcp_runtime(ctx: &mut RequestContext, server_ids: &[String]) -> Result<()> {
|
||||
if !ctx.app.config.mcp_server_support {
|
||||
return Ok(());
|
||||
@@ -601,11 +705,25 @@ async fn handle_check(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
|
||||
match is_finished {
|
||||
Some(true) => handle_collect(ctx, args).await,
|
||||
Some(false) => Ok(json!({
|
||||
"status": "pending",
|
||||
"id": id,
|
||||
"message": "Agent is still running"
|
||||
})),
|
||||
Some(false) => {
|
||||
let mut result = json!({
|
||||
"status": "pending",
|
||||
"id": id,
|
||||
"message": "Agent is still running"
|
||||
});
|
||||
|
||||
if let Some(queue) = ctx.root_escalation_queue()
|
||||
&& queue.has_pending()
|
||||
{
|
||||
let summary = queue.pending_summary();
|
||||
result["pending_escalations"] = json!(summary);
|
||||
result["message"] = json!(
|
||||
"Agent is still running. Child agents have pending escalations that need your reply via agent__reply_escalation."
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
None => Ok(json!({
|
||||
"status": "error",
|
||||
"message": format!("No agent found with id '{id}'")
|
||||
@@ -619,12 +737,48 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("'id' is required"))?;
|
||||
|
||||
let supervisor = ctx
|
||||
.supervisor
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("No supervisor active"))?;
|
||||
|
||||
{
|
||||
let sup = supervisor.read();
|
||||
if sup.is_finished(id).is_none() {
|
||||
return Ok(json!({
|
||||
"status": "error",
|
||||
"message": format!("Agent '{id}' not found. Use agent__check to verify it exists and is finished.")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
let is_finished = {
|
||||
let sup = supervisor.read();
|
||||
sup.is_finished(id).unwrap_or(false)
|
||||
};
|
||||
|
||||
if is_finished {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(queue) = ctx.root_escalation_queue()
|
||||
&& queue.has_pending()
|
||||
{
|
||||
let summary = queue.pending_summary();
|
||||
return Ok(json!({
|
||||
"status": "pending",
|
||||
"id": id,
|
||||
"message": format!("Agent '{id}' is still running, but child agents have pending escalations that need your reply. Reply via agent__reply_escalation, then call agent__collect again."),
|
||||
"pending_escalations": summary,
|
||||
}));
|
||||
}
|
||||
|
||||
time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
let handle = {
|
||||
let supervisor = ctx
|
||||
.supervisor
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("No supervisor active"))?;
|
||||
let mut sup = supervisor.write();
|
||||
sup.take(id)
|
||||
};
|
||||
@@ -649,7 +803,7 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
||||
}
|
||||
None => Ok(json!({
|
||||
"status": "error",
|
||||
"message": format!("Agent '{id}' not found. Use agent__check to verify it exists and is finished.")
|
||||
"message": format!("Agent '{id}' completed but could not be collected. It may have been collected by another call.")
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -1193,7 +1347,7 @@ mod tests {
|
||||
let inbox = Arc::new(Inbox::new());
|
||||
let abort = create_abort_signal();
|
||||
let join_handle = tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
time::sleep(Duration::from_secs(60)).await;
|
||||
Ok(AgentResult {
|
||||
id: "slow".into(),
|
||||
agent_name: "test".into(),
|
||||
|
||||
@@ -94,8 +94,14 @@ pub fn handle_todo_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value)
|
||||
.strip_prefix(TODO_FUNCTION_PREFIX)
|
||||
.unwrap_or(cmd_name);
|
||||
|
||||
if ctx.agent.is_none() {
|
||||
bail!("No active agent");
|
||||
if !ctx.app.config.function_calling_support {
|
||||
bail!("Cannot use todo tools: function calling is disabled.");
|
||||
}
|
||||
let auto_config = ctx.auto_continue_config();
|
||||
if !auto_config.enabled {
|
||||
bail!(
|
||||
"Auto-continue is not enabled. Set 'auto_continue: true' in your config to use todo tools."
|
||||
);
|
||||
}
|
||||
|
||||
match action {
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::{FunctionDeclaration, JsonSchema};
|
||||
use crate::config::RequestContext;
|
||||
use crate::supervisor::escalation::{EscalationRequest, new_escalation_id};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use indexmap::IndexMap;
|
||||
use inquire::{Confirm, MultiSelect, Select, Text};
|
||||
use serde_json::{Value, json};
|
||||
@@ -155,7 +155,10 @@ fn handle_direct_ask(args: &Value) -> Result<Value> {
|
||||
let mut options = parse_options(args)?;
|
||||
options.push(CUSTOM_MULTI_CHOICE_ANSWER_OPTION.to_string());
|
||||
|
||||
let mut answer = Select::new(question, options).prompt()?;
|
||||
let mut answer = Select::new(question, options)
|
||||
.without_filtering()
|
||||
.with_help_message("↑↓ to move, enter to select")
|
||||
.prompt()?;
|
||||
|
||||
if answer == CUSTOM_MULTI_CHOICE_ANSWER_OPTION {
|
||||
answer = Text::new("Custom response:").prompt()?
|
||||
@@ -205,12 +208,11 @@ async fn handle_escalated(ctx: &RequestContext, action: &str, args: &Value) -> R
|
||||
.ok_or_else(|| anyhow!("'question' is required"))?
|
||||
.to_string();
|
||||
|
||||
let options: Option<Vec<String>> = args.get("options").and_then(Value::as_array).map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(String::from)
|
||||
.collect()
|
||||
});
|
||||
let options: Option<Vec<String>> = if args.get("options").is_some() {
|
||||
Some(parse_options(args)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let from_agent_id = ctx
|
||||
.self_agent_id
|
||||
@@ -262,13 +264,24 @@ async fn handle_escalated(ctx: &RequestContext, action: &str, args: &Value) -> R
|
||||
}
|
||||
|
||||
fn parse_options(args: &Value) -> Result<Vec<String>> {
|
||||
args.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.ok_or_else(|| anyhow!("'options' is required and must be an array of strings"))
|
||||
let raw = args
|
||||
.get("options")
|
||||
.ok_or_else(|| anyhow!("'options' is required and must be an array of strings"))?;
|
||||
|
||||
let arr: Vec<Value> = match raw {
|
||||
Value::Array(arr) => arr.clone(),
|
||||
Value::String(s) => serde_json::from_str::<Vec<Value>>(s).map_err(|_| {
|
||||
anyhow!(
|
||||
"'options' was a string but did not parse as a JSON array. \
|
||||
Pass options as a native JSON array, e.g. [\"yes\", \"no\"]."
|
||||
)
|
||||
})?,
|
||||
_ => bail!("'options' is required and must be an array of strings"),
|
||||
};
|
||||
|
||||
Ok(arr
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(String::from)
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
use super::state::StateManager;
|
||||
use super::structured;
|
||||
use super::types::AgentNode;
|
||||
use crate::config::RequestContext;
|
||||
use crate::function::supervisor::run_agent_for_graph;
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const OUTPUT_KEY: &str = "output";
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
pub struct AgentNodeExecutor;
|
||||
|
||||
impl AgentNodeExecutor {
|
||||
pub async fn execute(
|
||||
node: &AgentNode,
|
||||
state_manager: &mut StateManager,
|
||||
parent_ctx: &mut RequestContext,
|
||||
) -> Result<String> {
|
||||
let prompt = state_manager
|
||||
.interpolate(&node.prompt)
|
||||
.with_context(|| format!("Failed to interpolate prompt for agent '{}'", node.agent))?;
|
||||
|
||||
let timeout_dur = Duration::from_secs(node.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS));
|
||||
|
||||
let raw = timeout(
|
||||
timeout_dur,
|
||||
run_agent_for_graph(parent_ctx, &node.agent, &prompt),
|
||||
)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Agent '{}' timed out after {}s",
|
||||
node.agent,
|
||||
timeout_dur.as_secs()
|
||||
)
|
||||
})?
|
||||
.with_context(|| format!("Agent '{}' failed", node.agent))?;
|
||||
|
||||
let output_value = match &node.output_schema {
|
||||
Some(schema) => structured::extract(&raw, schema, parent_ctx)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Agent '{}' output failed structured-output extraction",
|
||||
node.agent
|
||||
)
|
||||
})?,
|
||||
None => Value::String(raw.clone()),
|
||||
};
|
||||
|
||||
apply_state_updates(node, state_manager, &output_value);
|
||||
|
||||
Ok(raw)
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_state_updates(node: &AgentNode, state_manager: &mut StateManager, output: &Value) {
|
||||
if node.output_schema.is_some()
|
||||
&& let Some(obj) = output.as_object()
|
||||
{
|
||||
for (k, v) in obj {
|
||||
state_manager.state_mut().set(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let Some(updates) = &node.state_updates else {
|
||||
return;
|
||||
};
|
||||
let prev_output = state_manager.state().get(OUTPUT_KEY).cloned();
|
||||
state_manager
|
||||
.state_mut()
|
||||
.set(OUTPUT_KEY.into(), output.clone());
|
||||
|
||||
for (key, template) in updates {
|
||||
let value = state_manager.interpolate_lenient(template);
|
||||
state_manager
|
||||
.state_mut()
|
||||
.set(key.clone(), Value::String(value));
|
||||
}
|
||||
|
||||
match prev_output {
|
||||
Some(v) => state_manager.state_mut().set(OUTPUT_KEY.into(), v),
|
||||
None => {
|
||||
state_manager
|
||||
.state_mut()
|
||||
.set(OUTPUT_KEY.into(), Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::types::AgentNode;
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn manager_with(pairs: &[(&str, Value)]) -> StateManager {
|
||||
let mut map = HashMap::new();
|
||||
for (k, v) in pairs {
|
||||
map.insert((*k).into(), v.clone());
|
||||
}
|
||||
StateManager::new(map)
|
||||
}
|
||||
|
||||
fn node_with(prompt: &str, updates: Option<HashMap<String, String>>) -> AgentNode {
|
||||
AgentNode {
|
||||
agent: "test_agent".into(),
|
||||
prompt: prompt.into(),
|
||||
state_updates: updates,
|
||||
output_schema: None,
|
||||
timeout: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_updates_use_output_placeholder() {
|
||||
let node = {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("findings".into(), "{{output}}".into());
|
||||
node_with("hi", Some(u))
|
||||
};
|
||||
let mut state = manager_with(&[]);
|
||||
|
||||
apply_state_updates(&node, &mut state, &json!("agent finished its work"));
|
||||
|
||||
assert_eq!(
|
||||
state.state().get("findings"),
|
||||
Some(&json!("agent finished its work"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_updates_can_reference_existing_keys_and_output() {
|
||||
let node = {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("summary".into(), "{{topic}}: {{output}}".into());
|
||||
node_with("hi", Some(u))
|
||||
};
|
||||
let mut state = manager_with(&[("topic", json!("auth"))]);
|
||||
|
||||
apply_state_updates(&node, &mut state, &json!("JWT vs sessions"));
|
||||
|
||||
assert_eq!(
|
||||
state.state().get("summary"),
|
||||
Some(&json!("auth: JWT vs sessions"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_key_is_cleaned_up_after_state_updates() {
|
||||
let node = {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("findings".into(), "{{output}}".into());
|
||||
node_with("hi", Some(u))
|
||||
};
|
||||
let mut state = manager_with(&[]);
|
||||
|
||||
apply_state_updates(&node, &mut state, &json!("anything"));
|
||||
|
||||
assert_eq!(state.state().get("output"), Some(&Value::Null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_existing_output_value_is_preserved() {
|
||||
let node = {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("greeting".into(), "{{output}}".into());
|
||||
node_with("hi", Some(u))
|
||||
};
|
||||
let mut state = manager_with(&[("output", json!("preserved"))]);
|
||||
|
||||
apply_state_updates(&node, &mut state, &json!("new agent output"));
|
||||
|
||||
assert_eq!(
|
||||
state.state().get("greeting"),
|
||||
Some(&json!("new agent output"))
|
||||
);
|
||||
assert_eq!(state.state().get("output"), Some(&json!("preserved")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_state_updates_is_a_noop() {
|
||||
let node = node_with("hi", None);
|
||||
let mut state = manager_with(&[("k", json!("v"))]);
|
||||
|
||||
apply_state_updates(&node, &mut state, &json!("ignored"));
|
||||
|
||||
assert_eq!(state.state().get("k"), Some(&json!("v")));
|
||||
assert!(state.state().get("output").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolate_lenient_on_state_updates_handles_missing_keys() {
|
||||
let node = {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("decorated".into(), "[{{missing}}] {{output}}".into());
|
||||
node_with("hi", Some(u))
|
||||
};
|
||||
let mut state = manager_with(&[]);
|
||||
|
||||
apply_state_updates(&node, &mut state, &json!("DATA"));
|
||||
|
||||
assert_eq!(state.state().get("decorated"), Some(&json!("[] DATA")));
|
||||
}
|
||||
|
||||
fn node_with_schema(
|
||||
prompt: &str,
|
||||
updates: Option<HashMap<String, String>>,
|
||||
schema: Value,
|
||||
) -> AgentNode {
|
||||
let mut n = node_with(prompt, updates);
|
||||
n.output_schema = Some(schema);
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_auto_merges_top_level_keys() {
|
||||
let node = node_with_schema("hi", None, json!({"type": "object"}));
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({"goal": "do X", "summary": "details"});
|
||||
|
||||
apply_state_updates(&node, &mut state, &output);
|
||||
|
||||
assert_eq!(state.state().get("goal"), Some(&json!("do X")));
|
||||
assert_eq!(state.state().get("summary"), Some(&json!("details")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_preserves_nested_value_types() {
|
||||
let node = node_with_schema("hi", None, json!({"type": "object"}));
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({
|
||||
"tags": ["a", "b"],
|
||||
"config": { "key": "value" },
|
||||
"count": 42
|
||||
});
|
||||
|
||||
apply_state_updates(&node, &mut state, &output);
|
||||
|
||||
assert_eq!(state.state().get("tags"), Some(&json!(["a", "b"])));
|
||||
assert_eq!(state.state().get("config"), Some(&json!({"key": "value"})));
|
||||
assert_eq!(state.state().get("count"), Some(&json!(42)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_explicit_state_updates_override_auto_merge() {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("goal".into(), "renamed-{{output.goal}}".into());
|
||||
let node = node_with_schema("hi", Some(u), json!({"type": "object"}));
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({"goal": "do X"});
|
||||
|
||||
apply_state_updates(&node, &mut state, &output);
|
||||
|
||||
assert_eq!(state.state().get("goal"), Some(&json!("renamed-do X")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_schema_does_not_auto_merge() {
|
||||
let node = node_with("hi", None);
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({"goal": "do X"});
|
||||
|
||||
apply_state_updates(&node, &mut state, &output);
|
||||
|
||||
assert!(state.state().get("goal").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use super::{GraphExecutor, GraphParser, agent_has_graph};
|
||||
use crate::config::RequestContext;
|
||||
use crate::config::paths;
|
||||
use crate::utils::AbortSignal;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use log::info;
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn active_agent_graph_name(ctx: &RequestContext) -> Option<String> {
|
||||
let name = ctx.agent.as_ref()?.name().to_string();
|
||||
agent_has_graph(&name).then_some(name)
|
||||
}
|
||||
|
||||
pub async fn run_active_agent_graph(
|
||||
ctx: &mut RequestContext,
|
||||
prompt: &str,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<String> {
|
||||
let agent_name =
|
||||
active_agent_graph_name(ctx).ok_or_else(|| anyhow!("Active agent has no graph.yaml"))?;
|
||||
|
||||
info!("Agent '{agent_name}' has graph.yaml; routing to graph executor");
|
||||
|
||||
let agent_dir = paths::agent_data_dir(&agent_name);
|
||||
let graph_path = paths::agent_graph_file(&agent_name);
|
||||
|
||||
let parser = GraphParser::new(&agent_dir);
|
||||
let mut graph = parser
|
||||
.load_from_file(&graph_path)
|
||||
.with_context(|| format!("Failed to load graph.yaml for agent '{agent_name}'"))?;
|
||||
|
||||
graph
|
||||
.initial_state
|
||||
.insert("initial_prompt".into(), Value::String(prompt.to_string()));
|
||||
|
||||
let executor = GraphExecutor::new(graph, agent_dir);
|
||||
let output = executor
|
||||
.execute(ctx, abort_signal)
|
||||
.await
|
||||
.with_context(|| format!("Graph execution failed for agent '{agent_name}'"))?;
|
||||
|
||||
if let Some(supervisor) = ctx.supervisor.clone() {
|
||||
supervisor.read().cancel_all();
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
use super::agent::AgentNodeExecutor;
|
||||
use super::llm::{LlmExecutionOutcome, LlmNodeExecutor};
|
||||
use super::logging::{GraphLogger, narrate_node_complete, narrate_node_failed};
|
||||
use super::map::MapNodeExecutor;
|
||||
use super::rag::RagNodeExecutor;
|
||||
use super::script::ScriptExecutor;
|
||||
use super::staging::BranchWrites;
|
||||
use super::state::StateManager;
|
||||
use super::types::{EndNode, Graph, Node, NodeType};
|
||||
use super::user_interaction::{ApprovalNodeExecutor, InputNodeExecutor};
|
||||
use super::validator::{AgentValidationContext, GraphValidator};
|
||||
use crate::config::{RenderMode, RequestContext};
|
||||
use crate::utils::AbortSignal;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use futures_util::future::join_all;
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
pub struct GraphExecutor {
|
||||
graph: Graph,
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl GraphExecutor {
|
||||
pub fn new(graph: Graph, base_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
graph,
|
||||
base_dir: base_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
self,
|
||||
ctx: &mut RequestContext,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<String> {
|
||||
let is_nested = ctx.current_depth > 0;
|
||||
let mut logger = GraphLogger::with_visibility(
|
||||
&self.graph.name,
|
||||
self.graph.settings.log_state_snapshots,
|
||||
is_nested,
|
||||
);
|
||||
let result = self.run(&mut logger, ctx, abort_signal).await;
|
||||
if let Err(e) = &result {
|
||||
logger.graph_error(e);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self,
|
||||
logger: &mut GraphLogger,
|
||||
ctx: &mut RequestContext,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<String> {
|
||||
let GraphExecutor { graph, base_dir } = self;
|
||||
|
||||
if graph.settings.validate_before_run {
|
||||
let mut validator = GraphValidator::new(&base_dir);
|
||||
if let Some(agent) = &ctx.agent {
|
||||
validator = validator.with_agent_context(AgentValidationContext::from_agent(
|
||||
agent,
|
||||
Arc::clone(&ctx.app.config),
|
||||
));
|
||||
}
|
||||
let result = validator.validate(&graph);
|
||||
for w in &result.warnings {
|
||||
logger.validation_warning(w.node_id.as_deref(), &w.message);
|
||||
}
|
||||
result.into_result()?;
|
||||
}
|
||||
|
||||
let mut state = StateManager::new(graph.initial_state.clone());
|
||||
let agent_envs = ctx
|
||||
.agent
|
||||
.as_ref()
|
||||
.map(|a| a.variable_envs())
|
||||
.unwrap_or_default();
|
||||
let script_executor = ScriptExecutor::new(&base_dir).with_envs(agent_envs);
|
||||
let max_iterations = graph.settings.max_loop_iterations;
|
||||
let graph_timeout = graph.settings.timeout.map(Duration::from_secs);
|
||||
let max_concurrency = graph.settings.max_concurrency;
|
||||
let graph = Arc::new(graph);
|
||||
let start = Instant::now();
|
||||
|
||||
let mut frontier: HashSet<String> = HashSet::from([graph.start.clone()]);
|
||||
logger.graph_start(&graph.start, graph.nodes.len());
|
||||
|
||||
loop {
|
||||
if frontier.is_empty() {
|
||||
bail!(
|
||||
"Graph '{}' frontier emptied without reaching an End node",
|
||||
graph.name
|
||||
);
|
||||
}
|
||||
|
||||
if abort_signal.aborted() {
|
||||
bail!(
|
||||
"Graph '{}' aborted before super-step with frontier {:?}",
|
||||
graph.name,
|
||||
sorted_frontier(&frontier)
|
||||
);
|
||||
}
|
||||
if let Some(t) = graph_timeout
|
||||
&& start.elapsed() > t
|
||||
{
|
||||
bail!(
|
||||
"Graph '{}' timed out after {}s before super-step with frontier {:?}",
|
||||
graph.name,
|
||||
t.as_secs(),
|
||||
sorted_frontier(&frontier)
|
||||
);
|
||||
}
|
||||
|
||||
// Loop-count and visit tracking on live state, BEFORE forking.
|
||||
// This counts every entry to a node toward max_loop_iterations
|
||||
// regardless of how many parallel branches converged on it.
|
||||
for node_id in &frontier {
|
||||
state.state_mut().visit_node(node_id);
|
||||
let visits = state.state().loop_count(node_id);
|
||||
if visits > max_iterations {
|
||||
bail!(
|
||||
"Node '{}' visited {} times (max_loop_iterations={}). \
|
||||
Possible infinite loop.",
|
||||
node_id,
|
||||
visits,
|
||||
max_iterations
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for node_id in &frontier {
|
||||
let node = graph.get_node(node_id).ok_or_else(|| {
|
||||
anyhow!("Node '{}' not found in graph '{}'", node_id, graph.name)
|
||||
})?;
|
||||
let visits = state.state().loop_count(node_id);
|
||||
logger.node_entry(node, visits);
|
||||
}
|
||||
let snapshot_label = if frontier.len() == 1 {
|
||||
frontier.iter().next().cloned().unwrap_or_default()
|
||||
} else {
|
||||
format!("super-step {{{}}}", sorted_frontier(&frontier).join(","))
|
||||
};
|
||||
logger.state_snapshot(&snapshot_label, &state);
|
||||
|
||||
let snapshot = state.read_snapshot();
|
||||
let semaphore = Arc::new(Semaphore::new(max_concurrency));
|
||||
|
||||
let frontier_size = frontier.len();
|
||||
let in_super_step = frontier_size > 1;
|
||||
let silent = logger.silent();
|
||||
|
||||
if in_super_step {
|
||||
let mut branches = sorted_frontier(&frontier);
|
||||
branches.sort();
|
||||
logger.super_step_start(&branches);
|
||||
}
|
||||
|
||||
let mut branch_tasks = Vec::with_capacity(frontier_size);
|
||||
for node_id in &frontier {
|
||||
let node = graph
|
||||
.get_node(node_id)
|
||||
.ok_or_else(|| {
|
||||
anyhow!("Node '{}' not found in graph '{}'", node_id, graph.name)
|
||||
})?
|
||||
.clone();
|
||||
logger.node_start(&node, in_super_step);
|
||||
let branch_state = state.fork_for_branch_state();
|
||||
let mut branch_ctx = ctx.fork_for_branch();
|
||||
if in_super_step {
|
||||
branch_ctx.render_mode = RenderMode::Silent;
|
||||
}
|
||||
let script_exec_clone = script_executor.clone();
|
||||
let graph_clone = Arc::clone(&graph);
|
||||
let current = node_id.clone();
|
||||
let sem_clone = semaphore.clone();
|
||||
let abort_clone = abort_signal.clone();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let _permit = sem_clone
|
||||
.acquire()
|
||||
.await
|
||||
.expect("semaphore should not be closed");
|
||||
if abort_clone.aborted() {
|
||||
narrate_node_failed(
|
||||
silent,
|
||||
&node,
|
||||
Duration::default(),
|
||||
"aborted",
|
||||
in_super_step,
|
||||
);
|
||||
return (
|
||||
current.clone(),
|
||||
branch_state,
|
||||
Err(anyhow!("branch aborted")),
|
||||
Duration::default(),
|
||||
);
|
||||
}
|
||||
let node_start = Instant::now();
|
||||
let mut state = branch_state;
|
||||
let mut ctx = branch_ctx;
|
||||
let step_ctx = StepContext {
|
||||
graph: graph_clone.as_ref(),
|
||||
script_executor: &script_exec_clone,
|
||||
max_concurrency,
|
||||
abort_signal: &abort_clone,
|
||||
};
|
||||
let result = step(&node, &mut state, &mut ctx, &step_ctx, ¤t).await;
|
||||
let elapsed = node_start.elapsed();
|
||||
match &result {
|
||||
Ok(StepResult::Continue(targets)) => {
|
||||
let route = if targets.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(targets.join(", "))
|
||||
};
|
||||
narrate_node_complete(
|
||||
silent,
|
||||
&node,
|
||||
elapsed,
|
||||
route.as_deref(),
|
||||
in_super_step,
|
||||
);
|
||||
}
|
||||
Ok(StepResult::End(_)) => {
|
||||
narrate_node_complete(
|
||||
silent,
|
||||
&node,
|
||||
elapsed,
|
||||
Some("END"),
|
||||
in_super_step,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
narrate_node_failed(
|
||||
silent,
|
||||
&node,
|
||||
elapsed,
|
||||
&e.to_string(),
|
||||
in_super_step,
|
||||
);
|
||||
}
|
||||
}
|
||||
(current, state, result, elapsed)
|
||||
});
|
||||
branch_tasks.push(task);
|
||||
}
|
||||
|
||||
let joined = join_all(branch_tasks).await;
|
||||
|
||||
let mut branch_writes: Vec<BranchWrites> = Vec::new();
|
||||
let mut next_frontier: HashSet<String> = HashSet::new();
|
||||
let mut end_results: Vec<(String, StateManager, String)> = Vec::new();
|
||||
|
||||
for join_result in joined {
|
||||
let (node_id, branch_state, step_result, elapsed) =
|
||||
join_result.map_err(|e| anyhow!("Branch task panicked: {e}"))?;
|
||||
logger.record_timing(&node_id, elapsed);
|
||||
|
||||
let step_outcome = step_result.with_context(|| format!("at node '{node_id}'"))?;
|
||||
|
||||
match step_outcome {
|
||||
StepResult::Continue(targets) => {
|
||||
for target in &targets {
|
||||
logger.routing(&node_id, target);
|
||||
}
|
||||
let diff = branch_state.diff_against(snapshot.as_ref());
|
||||
branch_writes.push(BranchWrites {
|
||||
node_id: node_id.clone(),
|
||||
invocation_index: 0,
|
||||
writes: diff,
|
||||
});
|
||||
next_frontier.extend(targets);
|
||||
}
|
||||
StepResult::End(output) => {
|
||||
end_results.push((node_id.clone(), branch_state, output));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if end_results.len() > 1 {
|
||||
let mut ids: Vec<String> =
|
||||
end_results.iter().map(|(id, _, _)| id.clone()).collect();
|
||||
ids.sort();
|
||||
bail!(
|
||||
"super-step ended with multiple End targets ({}). \
|
||||
Fan-out branches must converge at a join node before \
|
||||
terminating. To fix: route all parallel branches to a \
|
||||
single shared next-node, then terminate from there.",
|
||||
ids.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by (node_id, invocation_index) so non-commutative reducers
|
||||
// like Concat/Merge produce deterministic output across runs.
|
||||
branch_writes.sort_by(|a, b| {
|
||||
a.node_id
|
||||
.cmp(&b.node_id)
|
||||
.then(a.invocation_index.cmp(&b.invocation_index))
|
||||
});
|
||||
state.apply_branch_writes(branch_writes, &graph.reducers)?;
|
||||
|
||||
if let Some((node_id, end_state, output)) = end_results.into_iter().next() {
|
||||
let diff = end_state.diff_against(snapshot.as_ref());
|
||||
state.apply_branch_writes(
|
||||
vec![BranchWrites {
|
||||
node_id: node_id.clone(),
|
||||
invocation_index: 0,
|
||||
writes: diff,
|
||||
}],
|
||||
&graph.reducers,
|
||||
)?;
|
||||
logger.graph_complete(&node_id, start.elapsed());
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
if in_super_step {
|
||||
logger.super_step_end(&sorted_frontier(&next_frontier));
|
||||
}
|
||||
frontier = next_frontier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sorted_frontier(frontier: &HashSet<String>) -> Vec<String> {
|
||||
let mut v: Vec<String> = frontier.iter().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
pub(super) struct StepContext<'a> {
|
||||
pub graph: &'a Graph,
|
||||
pub script_executor: &'a ScriptExecutor,
|
||||
pub max_concurrency: usize,
|
||||
pub abort_signal: &'a AbortSignal,
|
||||
}
|
||||
|
||||
impl StepContext<'_> {
|
||||
pub fn graph_name(&self) -> &str {
|
||||
&self.graph.name
|
||||
}
|
||||
}
|
||||
|
||||
enum StepResult {
|
||||
// The set of next-node ids the executor should add to the next super-step's
|
||||
// frontier. A `Vec` of length 1 for sequential routing (default) and the
|
||||
// full target list for fan-out (`next: [a, b, ...]`). Dynamic single-route
|
||||
// decisions (script `_next`, approval routes, LLM/RAG fallback) always emit
|
||||
// a single-element vec.
|
||||
Continue(Vec<String>),
|
||||
End(String),
|
||||
}
|
||||
|
||||
async fn step(
|
||||
node: &Node,
|
||||
state: &mut StateManager,
|
||||
ctx: &mut RequestContext,
|
||||
step_ctx: &StepContext<'_>,
|
||||
current: &str,
|
||||
) -> Result<StepResult> {
|
||||
match &node.node_type {
|
||||
NodeType::Agent(agent_node) => {
|
||||
AgentNodeExecutor::execute(agent_node, state, ctx).await?;
|
||||
let targets = static_next_targets(node, current, "agent")?;
|
||||
Ok(StepResult::Continue(targets))
|
||||
}
|
||||
NodeType::Script(script_node) => {
|
||||
let dynamic = match step_ctx.script_executor.execute(script_node, state).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
if let Some(fallback) = &script_node.fallback {
|
||||
warn!(
|
||||
"[graph:{}] script '{}' failed, routing to fallback '{}': {}",
|
||||
step_ctx.graph_name(),
|
||||
current,
|
||||
fallback,
|
||||
e
|
||||
);
|
||||
return Ok(StepResult::Continue(vec![fallback.clone()]));
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let targets = match dynamic {
|
||||
Some(n) => vec![n],
|
||||
None => static_next_targets(node, current, "script")?,
|
||||
};
|
||||
Ok(StepResult::Continue(targets))
|
||||
}
|
||||
NodeType::Approval(approval_node) => {
|
||||
let next = ApprovalNodeExecutor::execute(approval_node, state, ctx).await?;
|
||||
Ok(StepResult::Continue(vec![next]))
|
||||
}
|
||||
NodeType::Input(input_node) => {
|
||||
let next_id = first_next_target(node);
|
||||
let next = InputNodeExecutor::execute(input_node, next_id, state, ctx).await?;
|
||||
Ok(StepResult::Continue(vec![next]))
|
||||
}
|
||||
NodeType::Llm(llm_node) => {
|
||||
let outcome = LlmNodeExecutor::execute(llm_node, state, ctx).await?;
|
||||
let targets = match outcome {
|
||||
LlmExecutionOutcome::Continue => static_next_targets(node, current, "llm")?,
|
||||
LlmExecutionOutcome::FellBack(target) => vec![target],
|
||||
};
|
||||
Ok(StepResult::Continue(targets))
|
||||
}
|
||||
NodeType::Rag(rag_node) => {
|
||||
RagNodeExecutor::execute(rag_node, current, state, ctx).await?;
|
||||
let targets = static_next_targets(node, current, "rag")?;
|
||||
Ok(StepResult::Continue(targets))
|
||||
}
|
||||
NodeType::End(end_node) => Ok(StepResult::End(resolve_end_output(end_node, state))),
|
||||
NodeType::Map(map_node) => {
|
||||
let targets = static_next_targets(node, current, "map")?;
|
||||
MapNodeExecutor::execute(map_node, state, ctx, step_ctx, current).await?;
|
||||
Ok(StepResult::Continue(targets))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn static_next_targets(node: &Node, current: &str, kind: &str) -> Result<Vec<String>> {
|
||||
node.next
|
||||
.as_ref()
|
||||
.map(|t| t.as_slice().to_vec())
|
||||
.ok_or_else(|| anyhow!("{kind} node '{current}' has no `next` and is not an end node"))
|
||||
}
|
||||
|
||||
fn first_next_target(node: &Node) -> Option<&str> {
|
||||
node.next
|
||||
.as_ref()
|
||||
.and_then(|t| t.as_slice().first().map(|s| s.as_str()))
|
||||
}
|
||||
|
||||
fn resolve_end_output(end_node: &EndNode, state: &mut StateManager) -> String {
|
||||
apply_simple_state_updates(end_node.state_updates.as_ref(), state);
|
||||
state.interpolate_lenient(&end_node.output)
|
||||
}
|
||||
|
||||
fn apply_simple_state_updates(updates: Option<&HashMap<String, String>>, state: &mut StateManager) {
|
||||
let Some(updates) = updates else {
|
||||
return;
|
||||
};
|
||||
for (key, template) in updates {
|
||||
let value = state.interpolate_lenient(template);
|
||||
state.state_mut().set(key.clone(), Value::String(value));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn state_with(pairs: &[(&str, Value)]) -> StateManager {
|
||||
let mut map = HashMap::new();
|
||||
for (k, v) in pairs {
|
||||
map.insert((*k).into(), v.clone());
|
||||
}
|
||||
StateManager::new(map)
|
||||
}
|
||||
|
||||
fn end_node(output: &str, updates: Option<HashMap<String, String>>) -> EndNode {
|
||||
EndNode {
|
||||
output: output.into(),
|
||||
state_updates: updates,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_end_output_interpolates_template_against_state() {
|
||||
let mut state = state_with(&[("name", json!("alice"))]);
|
||||
|
||||
let node = end_node("done: {{name}}", None);
|
||||
|
||||
assert_eq!(resolve_end_output(&node, &mut state), "done: alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_end_output_applies_state_updates_before_interpolation() {
|
||||
let mut updates = HashMap::new();
|
||||
updates.insert("summary".into(), "completed for {{user}}".into());
|
||||
let node = end_node("RESULT: {{summary}}", Some(updates));
|
||||
|
||||
let mut state = state_with(&[("user", json!("bob"))]);
|
||||
|
||||
assert_eq!(
|
||||
resolve_end_output(&node, &mut state),
|
||||
"RESULT: completed for bob"
|
||||
);
|
||||
assert_eq!(
|
||||
state.state().get("summary"),
|
||||
Some(&json!("completed for bob"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_end_output_with_empty_template_returns_empty_string() {
|
||||
let mut state = state_with(&[]);
|
||||
|
||||
let node = end_node("", None);
|
||||
|
||||
assert_eq!(resolve_end_output(&node, &mut state), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_end_output_lenient_on_missing_keys() {
|
||||
let mut state = state_with(&[]);
|
||||
|
||||
let node = end_node("hello {{unknown}}!", None);
|
||||
|
||||
assert_eq!(resolve_end_output(&node, &mut state), "hello !");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_state_updates_does_nothing_when_none() {
|
||||
let mut state = state_with(&[("k", json!("v"))]);
|
||||
|
||||
apply_simple_state_updates(None, &mut state);
|
||||
|
||||
assert_eq!(state.state().get("k"), Some(&json!("v")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_state_updates_overwrites_existing_values() {
|
||||
let mut updates = HashMap::new();
|
||||
updates.insert("k".into(), "new-{{k}}".into());
|
||||
let mut state = state_with(&[("k", json!("old"))]);
|
||||
|
||||
apply_simple_state_updates(Some(&updates), &mut state);
|
||||
|
||||
assert_eq!(state.state().get("k"), Some(&json!("new-old")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
use crate::config::{AppState, WorkingMode};
|
||||
use crate::utils::{create_abort_signal, temp_file};
|
||||
use std::fs;
|
||||
|
||||
fn cmd_available(name: &str) -> bool {
|
||||
which::which(name).is_ok()
|
||||
}
|
||||
|
||||
struct TestWorkspace {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl TestWorkspace {
|
||||
fn new() -> Self {
|
||||
let dir = temp_file("-graph-integration-", "");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
fn write_script(&self, name: &str, contents: &str) {
|
||||
fs::write(self.dir.join(name), contents).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestWorkspace {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_ctx() -> RequestContext {
|
||||
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn static_fan_out_merges_branch_writes_via_append_reducer() {
|
||||
if !cmd_available("bash") {
|
||||
eprintln!("skipping: bash not available");
|
||||
return;
|
||||
}
|
||||
let ws = TestWorkspace::new();
|
||||
ws.write_script("dispatcher.sh", "#!/bin/bash\necho '{}'\n");
|
||||
ws.write_script(
|
||||
"worker_a.sh",
|
||||
"#!/bin/bash\necho '{\"results\": \"alpha\"}'\n",
|
||||
);
|
||||
ws.write_script(
|
||||
"worker_b.sh",
|
||||
"#!/bin/bash\necho '{\"results\": \"beta\"}'\n",
|
||||
);
|
||||
|
||||
let yaml = r#"
|
||||
name: static_fan_out_test
|
||||
start: dispatcher
|
||||
reducers:
|
||||
results: append
|
||||
nodes:
|
||||
dispatcher:
|
||||
type: script
|
||||
script: dispatcher.sh
|
||||
state_updates: {}
|
||||
next: [worker_a, worker_b]
|
||||
worker_a:
|
||||
type: script
|
||||
script: worker_a.sh
|
||||
state_updates: {}
|
||||
next: join
|
||||
worker_b:
|
||||
type: script
|
||||
script: worker_b.sh
|
||||
state_updates: {}
|
||||
next: join
|
||||
join:
|
||||
type: end
|
||||
output: "{{results}}"
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
let mut ctx = make_ctx();
|
||||
let abort = create_abort_signal();
|
||||
let result = GraphExecutor::new(graph, &ws.dir)
|
||||
.execute(&mut ctx, abort)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result)
|
||||
.unwrap_or_else(|_| panic!("expected JSON array, got: {result}"));
|
||||
let arr = parsed.as_array().expect("results should be an array");
|
||||
assert_eq!(arr.len(), 2, "expected 2 elements, got: {result}");
|
||||
let strs: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(strs.contains(&"alpha"), "missing 'alpha' in {strs:?}");
|
||||
assert!(strs.contains(&"beta"), "missing 'beta' in {strs:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn map_over_list_collects_outputs_in_input_order() {
|
||||
if !cmd_available("python3") {
|
||||
eprintln!("skipping: python3 not available");
|
||||
return;
|
||||
}
|
||||
let ws = TestWorkspace::new();
|
||||
ws.write_script(
|
||||
"doubler.py",
|
||||
r#"#!/usr/bin/env python3
|
||||
import os, json
|
||||
state = json.loads(os.environ.get("GRAPH_STATE", "{}"))
|
||||
val = state["item"]
|
||||
print(json.dumps({"output": val * 2}))
|
||||
"#,
|
||||
);
|
||||
|
||||
let yaml = r#"
|
||||
name: map_input_order_test
|
||||
start: fan_out
|
||||
initial_state:
|
||||
items: [1, 2, 3, 4, 5]
|
||||
nodes:
|
||||
fan_out:
|
||||
type: map
|
||||
over: "{{items}}"
|
||||
as: item
|
||||
branch: doubler
|
||||
collect_into: doubled
|
||||
next: done
|
||||
doubler:
|
||||
type: script
|
||||
script: doubler.py
|
||||
state_updates: {}
|
||||
done:
|
||||
type: end
|
||||
output: "{{doubled}}"
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
let mut ctx = make_ctx();
|
||||
let abort = create_abort_signal();
|
||||
let result = GraphExecutor::new(graph, &ws.dir)
|
||||
.execute(&mut ctx, abort)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result)
|
||||
.unwrap_or_else(|_| panic!("expected JSON array, got: {result}"));
|
||||
let arr = parsed.as_array().expect("doubled should be an array");
|
||||
let nums: Vec<i64> = arr
|
||||
.iter()
|
||||
.map(|v| v.as_i64().expect("each item should be int"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
nums,
|
||||
vec![2, 4, 6, 8, 10],
|
||||
"map outputs should be in input order, not finish order"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parallel_branch_error_aborts_super_step() {
|
||||
if !cmd_available("bash") {
|
||||
eprintln!("skipping: bash not available");
|
||||
return;
|
||||
}
|
||||
let ws = TestWorkspace::new();
|
||||
ws.write_script("dispatcher.sh", "#!/bin/bash\necho '{}'\n");
|
||||
ws.write_script(
|
||||
"worker_ok.sh",
|
||||
"#!/bin/bash\necho '{\"results\": \"ok\"}'\n",
|
||||
);
|
||||
ws.write_script(
|
||||
"worker_fail.sh",
|
||||
"#!/bin/bash\necho 'simulated failure' >&2\nexit 1\n",
|
||||
);
|
||||
|
||||
let yaml = r#"
|
||||
name: branch_error_test
|
||||
start: dispatcher
|
||||
reducers:
|
||||
results: append
|
||||
nodes:
|
||||
dispatcher:
|
||||
type: script
|
||||
script: dispatcher.sh
|
||||
state_updates: {}
|
||||
next: [worker_ok, worker_fail]
|
||||
worker_ok:
|
||||
type: script
|
||||
script: worker_ok.sh
|
||||
state_updates: {}
|
||||
next: join
|
||||
worker_fail:
|
||||
type: script
|
||||
script: worker_fail.sh
|
||||
state_updates: {}
|
||||
next: join
|
||||
join:
|
||||
type: end
|
||||
output: "{{results}}"
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
let mut ctx = make_ctx();
|
||||
let abort = create_abort_signal();
|
||||
let result = GraphExecutor::new(graph, &ws.dir)
|
||||
.execute(&mut ctx, abort)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "expected branch error to propagate");
|
||||
let err = format!("{:#}", result.unwrap_err());
|
||||
assert!(
|
||||
err.contains("worker_fail"),
|
||||
"error should mention failing node: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_end_in_super_step_is_rejected() {
|
||||
if !cmd_available("bash") {
|
||||
eprintln!("skipping: bash not available");
|
||||
return;
|
||||
}
|
||||
let ws = TestWorkspace::new();
|
||||
ws.write_script("dispatcher.sh", "#!/bin/bash\necho '{}'\n");
|
||||
|
||||
let yaml = r#"
|
||||
name: multi_end_test
|
||||
start: dispatcher
|
||||
nodes:
|
||||
dispatcher:
|
||||
type: script
|
||||
script: dispatcher.sh
|
||||
state_updates: {}
|
||||
next: [end_a, end_b]
|
||||
end_a:
|
||||
type: end
|
||||
output: "from a"
|
||||
end_b:
|
||||
type: end
|
||||
output: "from b"
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
let mut ctx = make_ctx();
|
||||
let abort = create_abort_signal();
|
||||
let result = GraphExecutor::new(graph, &ws.dir)
|
||||
.execute(&mut ctx, abort)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "expected multi-End to be rejected");
|
||||
let err = format!("{:#}", result.unwrap_err());
|
||||
assert!(
|
||||
err.contains("multiple End targets"),
|
||||
"error should explain multi-End cause: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains("end_a") && err.contains("end_b"),
|
||||
"error should list both End nodes: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
use super::state::StateManager;
|
||||
use super::structured;
|
||||
use super::types::LlmNode;
|
||||
use crate::client::{Model, ModelType, call_chat_completions};
|
||||
use crate::config::{Input, RequestContext, Role, RoleLike};
|
||||
use crate::utils::create_abort_signal;
|
||||
use anyhow::{Context, Error, Result, anyhow, bail};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const OUTPUT_KEY: &str = "output";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum LlmExecutionOutcome {
|
||||
Continue,
|
||||
FellBack(String),
|
||||
}
|
||||
|
||||
pub struct LlmNodeExecutor;
|
||||
|
||||
impl LlmNodeExecutor {
|
||||
pub(super) async fn execute(
|
||||
node: &LlmNode,
|
||||
state_manager: &mut StateManager,
|
||||
parent_ctx: &mut RequestContext,
|
||||
) -> Result<LlmExecutionOutcome> {
|
||||
let result = run(node, state_manager, parent_ctx).await;
|
||||
let (output, failure_reason) = match result {
|
||||
Ok(raw) => match &node.output_schema {
|
||||
Some(schema) => match structured::extract(&raw, schema, parent_ctx).await {
|
||||
Ok(value) => (value, None),
|
||||
Err(e) => {
|
||||
warn!("llm node structured extraction failed: {e}");
|
||||
(
|
||||
Value::String(format!("LLM node structured-extraction failed: {e}")),
|
||||
Some(format!("structured-extraction failed: {e}")),
|
||||
)
|
||||
}
|
||||
},
|
||||
None => (Value::String(raw), None),
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("llm node failed: {e}");
|
||||
(
|
||||
Value::String(format!("LLM node failed: {e}")),
|
||||
Some(format!("LLM call failed: {e:#}")),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
apply_state_updates_with_output(node, state_manager, &output);
|
||||
outcome_from(failure_reason.as_deref(), node.fallback.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
fn outcome_from(
|
||||
failure_reason: Option<&str>,
|
||||
fallback: Option<&str>,
|
||||
) -> Result<LlmExecutionOutcome> {
|
||||
match (failure_reason, fallback) {
|
||||
(None, _) => Ok(LlmExecutionOutcome::Continue),
|
||||
(Some(_), Some(fb)) => Ok(LlmExecutionOutcome::FellBack(fb.to_string())),
|
||||
(Some(reason), None) => bail!(
|
||||
"LLM node failed and no fallback declared: {reason}. \
|
||||
Add a `fallback:` route on the node to route on failure, \
|
||||
or fix the underlying error."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
node: &LlmNode,
|
||||
state_manager: &mut StateManager,
|
||||
parent_ctx: &mut RequestContext,
|
||||
) -> Result<String> {
|
||||
let mut instructions: Option<String> = match &node.instructions {
|
||||
Some(s) => Some(
|
||||
state_manager
|
||||
.interpolate(s)
|
||||
.context("Failed to interpolate llm node instructions")?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let mut prompt = state_manager
|
||||
.interpolate(&node.prompt)
|
||||
.context("Failed to interpolate llm node prompt")?;
|
||||
|
||||
if let Some(schema) = &node.output_schema {
|
||||
let hint = format_schema_hint(schema);
|
||||
match instructions.as_mut() {
|
||||
Some(s) => {
|
||||
s.push_str("\n\n");
|
||||
s.push_str(&hint);
|
||||
}
|
||||
None => {
|
||||
prompt.push_str("\n\n");
|
||||
prompt.push_str(&hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (regular_tools, mcp_servers) = categorize_tools(node.tools.as_deref());
|
||||
validate_tools_subset(®ular_tools, &mcp_servers, parent_ctx)?;
|
||||
|
||||
let role = build_inline_role(
|
||||
node,
|
||||
instructions.as_deref(),
|
||||
®ular_tools,
|
||||
&mcp_servers,
|
||||
parent_ctx,
|
||||
)?;
|
||||
|
||||
let saved_role = parent_ctx.role.clone();
|
||||
parent_ctx.role = Some(role);
|
||||
let result = match node.timeout {
|
||||
Some(secs) => match timeout(
|
||||
Duration::from_secs(secs),
|
||||
run_with_retries(node, &prompt, parent_ctx),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(anyhow!("llm node timed out after {secs}s")),
|
||||
},
|
||||
None => run_with_retries(node, &prompt, parent_ctx).await,
|
||||
};
|
||||
parent_ctx.role = saved_role;
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_with_retries(
|
||||
node: &LlmNode,
|
||||
prompt: &str,
|
||||
ctx: &mut RequestContext,
|
||||
) -> Result<String> {
|
||||
let mut last_err: Option<Error> = None;
|
||||
for attempt in 1..=node.max_attempts {
|
||||
match run_chat_loop(node, prompt, ctx).await {
|
||||
Ok(out) => return Ok(out),
|
||||
Err(e) if is_transient(&e) && attempt < node.max_attempts => {
|
||||
warn!("llm node attempt {attempt} failed (transient): {e}; retrying");
|
||||
last_err = Some(e);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow!("llm node exhausted retries")))
|
||||
}
|
||||
|
||||
async fn run_chat_loop(node: &LlmNode, prompt: &str, ctx: &mut RequestContext) -> Result<String> {
|
||||
let abort = create_abort_signal();
|
||||
let app_cfg = Arc::clone(&ctx.app.config);
|
||||
let role_for_input = ctx.role.clone();
|
||||
let mut input = Input::from_str(ctx, prompt, role_for_input);
|
||||
let mut accumulated = String::new();
|
||||
|
||||
for turn in 0..node.max_iterations {
|
||||
let client = input.create_client()?;
|
||||
ctx.before_chat_completion(&input)?;
|
||||
let (output, tool_results) =
|
||||
call_chat_completions(&input, false, false, client.as_ref(), ctx, abort.clone())
|
||||
.await?;
|
||||
ctx.after_chat_completion(app_cfg.as_ref(), &input, &output, &tool_results)?;
|
||||
|
||||
if !output.is_empty() {
|
||||
if !accumulated.is_empty() {
|
||||
accumulated.push('\n');
|
||||
}
|
||||
accumulated.push_str(&output);
|
||||
}
|
||||
|
||||
if tool_results.is_empty() {
|
||||
return Ok(accumulated);
|
||||
}
|
||||
|
||||
if turn + 1 == node.max_iterations {
|
||||
bail!(
|
||||
"llm node hit max_iterations ({}) before LLM concluded",
|
||||
node.max_iterations
|
||||
);
|
||||
}
|
||||
|
||||
input = input.merge_tool_results(output, tool_results);
|
||||
}
|
||||
|
||||
bail!("llm node ended without producing output")
|
||||
}
|
||||
|
||||
fn build_inline_role(
|
||||
node: &LlmNode,
|
||||
instructions: Option<&str>,
|
||||
regular_tools: &[String],
|
||||
mcp_servers: &[String],
|
||||
parent_ctx: &RequestContext,
|
||||
) -> Result<Role> {
|
||||
let mut role = Role::new("llm_node", instructions.unwrap_or(""));
|
||||
|
||||
let model = match &node.model {
|
||||
Some(model_id) => {
|
||||
Model::retrieve_model(parent_ctx.app.config.as_ref(), model_id, ModelType::Chat)
|
||||
.with_context(|| format!("Unknown model '{model_id}' on llm node"))?
|
||||
}
|
||||
None => parent_ctx.current_model().clone(),
|
||||
};
|
||||
role.set_model(model);
|
||||
|
||||
if let Some(t) = node.temperature {
|
||||
role.set_temperature(Some(t));
|
||||
}
|
||||
if let Some(p) = node.top_p {
|
||||
role.set_top_p(Some(p));
|
||||
}
|
||||
|
||||
if node.tools.as_deref().unwrap_or_default().is_empty() {
|
||||
role.set_enabled_tools(Some(String::new()));
|
||||
role.set_enabled_mcp_servers(Some(String::new()));
|
||||
} else {
|
||||
if !regular_tools.is_empty() {
|
||||
role.set_enabled_tools(Some(regular_tools.join(",")));
|
||||
} else {
|
||||
role.set_enabled_tools(Some(String::new()));
|
||||
}
|
||||
if !mcp_servers.is_empty() {
|
||||
role.set_enabled_mcp_servers(Some(mcp_servers.join(",")));
|
||||
} else {
|
||||
role.set_enabled_mcp_servers(Some(String::new()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(role)
|
||||
}
|
||||
|
||||
fn categorize_tools(entries: Option<&[String]>) -> (Vec<String>, Vec<String>) {
|
||||
let mut regular = Vec::new();
|
||||
let mut mcp = Vec::new();
|
||||
let Some(entries) = entries else {
|
||||
return (regular, mcp);
|
||||
};
|
||||
|
||||
for e in entries {
|
||||
if let Some(server) = e.strip_prefix("mcp:") {
|
||||
mcp.push(server.to_string());
|
||||
} else {
|
||||
regular.push(e.clone());
|
||||
}
|
||||
}
|
||||
|
||||
(regular, mcp)
|
||||
}
|
||||
|
||||
fn validate_tools_subset(
|
||||
regular: &[String],
|
||||
mcp_servers: &[String],
|
||||
parent_ctx: &RequestContext,
|
||||
) -> Result<()> {
|
||||
let agent = parent_ctx
|
||||
.agent
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("llm node requires an active agent"))?;
|
||||
|
||||
if !regular.is_empty() {
|
||||
let known: HashSet<&str> = agent
|
||||
.functions()
|
||||
.declarations()
|
||||
.iter()
|
||||
.map(|d| d.name.as_str())
|
||||
.collect();
|
||||
for name in regular {
|
||||
if !known.contains(name.as_str()) {
|
||||
let mut avail: Vec<&str> = known.iter().copied().collect();
|
||||
avail.sort();
|
||||
bail!(
|
||||
"llm node references unknown tool '{name}'. Agent '{}' provides: {}",
|
||||
agent.name(),
|
||||
avail.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !mcp_servers.is_empty() {
|
||||
let known: HashSet<&str> = agent
|
||||
.mcp_server_names()
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
for server in mcp_servers {
|
||||
if !known.contains(server.as_str()) {
|
||||
let mut avail: Vec<&str> = known.iter().copied().collect();
|
||||
avail.sort();
|
||||
bail!(
|
||||
"llm node references unknown MCP server 'mcp:{server}'. \
|
||||
Agent '{}' has MCP servers: [{}]",
|
||||
agent.name(),
|
||||
avail.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_transient(err: &Error) -> bool {
|
||||
let s = format!("{err:#}");
|
||||
s.contains("timed out")
|
||||
|| s.contains("rate limit")
|
||||
|| s.contains("429")
|
||||
|| s.contains("Connection reset")
|
||||
|| s.contains("Connection refused")
|
||||
|| s.contains("produced no output")
|
||||
}
|
||||
|
||||
fn apply_state_updates_with_output(
|
||||
node: &LlmNode,
|
||||
state_manager: &mut StateManager,
|
||||
output: &Value,
|
||||
) {
|
||||
if node.output_schema.is_some()
|
||||
&& let Some(obj) = output.as_object()
|
||||
{
|
||||
for (k, v) in obj {
|
||||
state_manager.state_mut().set(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let Some(updates) = &node.state_updates else {
|
||||
return;
|
||||
};
|
||||
let prev_output = state_manager.state().get(OUTPUT_KEY).cloned();
|
||||
state_manager
|
||||
.state_mut()
|
||||
.set(OUTPUT_KEY.into(), output.clone());
|
||||
|
||||
for (key, template) in updates {
|
||||
let value = state_manager.interpolate_lenient(template);
|
||||
state_manager
|
||||
.state_mut()
|
||||
.set(key.clone(), Value::String(value));
|
||||
}
|
||||
|
||||
match prev_output {
|
||||
Some(v) => state_manager.state_mut().set(OUTPUT_KEY.into(), v),
|
||||
None => {
|
||||
state_manager
|
||||
.state_mut()
|
||||
.set(OUTPUT_KEY.into(), Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_schema_hint(schema: &Value) -> String {
|
||||
let schema_json = serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string());
|
||||
format!(
|
||||
"Respond with a JSON object that matches this schema. Output ONLY the JSON \
|
||||
object with no surrounding prose or markdown fences.\n\nSchema:\n{schema_json}"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::types::*;
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn manager_with(pairs: &[(&str, Value)]) -> StateManager {
|
||||
let mut map = HashMap::new();
|
||||
for (k, v) in pairs {
|
||||
map.insert((*k).into(), v.clone());
|
||||
}
|
||||
StateManager::new(map)
|
||||
}
|
||||
|
||||
fn node_with(updates: Option<HashMap<String, String>>) -> LlmNode {
|
||||
LlmNode {
|
||||
instructions: Some("sys".into()),
|
||||
prompt: "user".into(),
|
||||
tools: None,
|
||||
model: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
fallback: None,
|
||||
max_attempts: 1,
|
||||
max_iterations: 10,
|
||||
state_updates: updates,
|
||||
output_schema: None,
|
||||
timeout: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_updates_expose_output_during_evaluation() {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("response".into(), "{{output}}".into());
|
||||
let node = node_with(Some(u));
|
||||
let mut state = manager_with(&[]);
|
||||
|
||||
apply_state_updates_with_output(&node, &mut state, &json!("the answer"));
|
||||
|
||||
assert_eq!(state.state().get("response"), Some(&json!("the answer")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_updates_can_mix_existing_keys_with_output() {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("summary".into(), "{{topic}}: {{output}}".into());
|
||||
let node = node_with(Some(u));
|
||||
let mut state = manager_with(&[("topic", json!("LOINC"))]);
|
||||
|
||||
apply_state_updates_with_output(&node, &mut state, &json!("abc"));
|
||||
|
||||
assert_eq!(state.state().get("summary"), Some(&json!("LOINC: abc")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_key_is_cleared_after_state_updates() {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("k".into(), "{{output}}".into());
|
||||
let node = node_with(Some(u));
|
||||
let mut state = manager_with(&[]);
|
||||
|
||||
apply_state_updates_with_output(&node, &mut state, &json!("anything"));
|
||||
|
||||
assert_eq!(state.state().get(OUTPUT_KEY), Some(&json!(null)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_existing_output_value_is_restored() {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("greeting".into(), "{{output}}".into());
|
||||
let node = node_with(Some(u));
|
||||
let mut state = manager_with(&[("output", json!("preserved"))]);
|
||||
|
||||
apply_state_updates_with_output(&node, &mut state, &json!("new"));
|
||||
|
||||
assert_eq!(state.state().get("greeting"), Some(&json!("new")));
|
||||
assert_eq!(state.state().get(OUTPUT_KEY), Some(&json!("preserved")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_state_updates_is_a_noop() {
|
||||
let node = node_with(None);
|
||||
let mut state = manager_with(&[("k", json!("v"))]);
|
||||
|
||||
apply_state_updates_with_output(&node, &mut state, &json!("x"));
|
||||
|
||||
assert_eq!(state.state().get("k"), Some(&json!("v")));
|
||||
assert!(state.state().get(OUTPUT_KEY).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_from_success_is_continue() {
|
||||
assert_eq!(
|
||||
outcome_from(None, Some("fb")).unwrap(),
|
||||
LlmExecutionOutcome::Continue
|
||||
);
|
||||
assert_eq!(
|
||||
outcome_from(None, None).unwrap(),
|
||||
LlmExecutionOutcome::Continue
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_from_failure_with_fallback_is_fell_back() {
|
||||
assert_eq!(
|
||||
outcome_from(Some("HTTP 404"), Some("fb")).unwrap(),
|
||||
LlmExecutionOutcome::FellBack("fb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_from_failure_without_fallback_propagates_error() {
|
||||
let err = outcome_from(Some("HTTP 404"), None).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("no fallback declared"), "got: {msg}");
|
||||
assert!(msg.contains("HTTP 404"), "got: {msg}");
|
||||
}
|
||||
|
||||
fn node_with_schema(updates: Option<HashMap<String, String>>, schema: Value) -> LlmNode {
|
||||
let mut n = node_with(updates);
|
||||
n.output_schema = Some(schema);
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_auto_merges_top_level_keys() {
|
||||
let node = node_with_schema(None, json!({"type": "object"}));
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({"goal": "do X", "summary": "details"});
|
||||
|
||||
apply_state_updates_with_output(&node, &mut state, &output);
|
||||
|
||||
assert_eq!(state.state().get("goal"), Some(&json!("do X")));
|
||||
assert_eq!(state.state().get("summary"), Some(&json!("details")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_preserves_nested_value_types() {
|
||||
let node = node_with_schema(None, json!({"type": "object"}));
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({
|
||||
"tags": ["a", "b"],
|
||||
"config": { "key": "value" },
|
||||
"count": 42
|
||||
});
|
||||
|
||||
apply_state_updates_with_output(&node, &mut state, &output);
|
||||
|
||||
assert_eq!(state.state().get("tags"), Some(&json!(["a", "b"])));
|
||||
assert_eq!(state.state().get("config"), Some(&json!({"key": "value"})));
|
||||
assert_eq!(state.state().get("count"), Some(&json!(42)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_explicit_state_updates_override_auto_merge() {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("goal".into(), "renamed-{{output.goal}}".into());
|
||||
let node = node_with_schema(Some(u), json!({"type": "object"}));
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({"goal": "do X"});
|
||||
|
||||
apply_state_updates_with_output(&node, &mut state, &output);
|
||||
|
||||
assert_eq!(state.state().get("goal"), Some(&json!("renamed-do X")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_skips_auto_merge_for_non_object() {
|
||||
let node = node_with_schema(None, json!({"type": "array"}));
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!([1, 2, 3]);
|
||||
|
||||
apply_state_updates_with_output(&node, &mut state, &output);
|
||||
|
||||
assert!(state.state().get("0").is_none());
|
||||
assert!(state.state().get(OUTPUT_KEY).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_schema_does_not_auto_merge() {
|
||||
let node = node_with(None);
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({"goal": "do X"});
|
||||
|
||||
apply_state_updates_with_output(&node, &mut state, &output);
|
||||
|
||||
assert!(state.state().get("goal").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_schema_hint_includes_schema_and_instruction() {
|
||||
let schema = json!({"type": "object", "properties": {"goal": {"type": "string"}}});
|
||||
|
||||
let hint = format_schema_hint(&schema);
|
||||
|
||||
assert!(hint.contains("Schema:"));
|
||||
assert!(hint.contains("\"goal\""));
|
||||
assert!(hint.contains("JSON"));
|
||||
assert!(hint.contains("ONLY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categorize_tools_splits_mcp_and_regular() {
|
||||
let entries = vec![
|
||||
"read_query".to_string(),
|
||||
"mcp:pubmed-search".to_string(),
|
||||
"web_search_coyote".to_string(),
|
||||
"mcp:github".to_string(),
|
||||
];
|
||||
|
||||
let (regular, mcp) = categorize_tools(Some(&entries));
|
||||
|
||||
assert_eq!(regular, vec!["read_query", "web_search_coyote"]);
|
||||
assert_eq!(mcp, vec!["pubmed-search", "github"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categorize_tools_with_none_returns_empty() {
|
||||
let (regular, mcp) = categorize_tools(None);
|
||||
|
||||
assert!(regular.is_empty());
|
||||
assert!(mcp.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categorize_tools_with_empty_returns_empty() {
|
||||
let (regular, mcp) = categorize_tools(Some(&[]));
|
||||
|
||||
assert!(regular.is_empty());
|
||||
assert!(mcp.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_transient_matches_expected_signatures() {
|
||||
assert!(is_transient(&anyhow!("request timed out after 30s")));
|
||||
assert!(is_transient(&anyhow!("rate limit reached")));
|
||||
assert!(is_transient(&anyhow!("429 too many requests")));
|
||||
assert!(is_transient(&anyhow!("Connection reset by peer")));
|
||||
assert!(is_transient(&anyhow!("Connection refused")));
|
||||
assert!(is_transient(&anyhow!("llm produced no output")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_transient_rejects_non_transient_errors() {
|
||||
assert!(!is_transient(&anyhow!("Unknown model 'foo'")));
|
||||
assert!(!is_transient(&anyhow!(
|
||||
"llm node references unknown tool 'bad'"
|
||||
)));
|
||||
assert!(!is_transient(&anyhow!("hit max_iterations")));
|
||||
assert!(!is_transient(&anyhow!("authentication failed")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
use super::state::StateManager;
|
||||
use super::types::{Node, NodeType};
|
||||
use crate::utils::dimmed_text;
|
||||
use chrono::Local;
|
||||
use indexmap::IndexMap;
|
||||
use std::cmp::Reverse;
|
||||
use std::time::Duration;
|
||||
|
||||
fn ts() -> String {
|
||||
Local::now().format("%H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
fn fmt_secs(elapsed: Duration) -> String {
|
||||
let secs = elapsed.as_secs_f64();
|
||||
if secs < 1.0 {
|
||||
format!("{}ms", elapsed.as_millis())
|
||||
} else {
|
||||
format!("{secs:.2}s")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct NodeTiming {
|
||||
count: usize,
|
||||
total: Duration,
|
||||
max: Duration,
|
||||
}
|
||||
|
||||
impl NodeTiming {
|
||||
fn record(&mut self, elapsed: Duration) {
|
||||
self.count += 1;
|
||||
self.total += elapsed;
|
||||
if elapsed > self.max {
|
||||
self.max = elapsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GraphLogger {
|
||||
graph_name: String,
|
||||
log_state_snapshots: bool,
|
||||
silent: bool,
|
||||
timings: IndexMap<String, NodeTiming>,
|
||||
}
|
||||
|
||||
impl GraphLogger {
|
||||
pub fn with_visibility(graph_name: &str, log_state_snapshots: bool, silent: bool) -> Self {
|
||||
Self {
|
||||
graph_name: graph_name.to_string(),
|
||||
log_state_snapshots,
|
||||
silent,
|
||||
timings: IndexMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn graph_start(&self, start_node: &str, node_count: usize) {
|
||||
info!(
|
||||
"[graph:{}] start at '{}' ({} nodes)",
|
||||
self.graph_name, start_node, node_count
|
||||
);
|
||||
if !self.silent {
|
||||
eprintln!(
|
||||
"{}",
|
||||
dimmed_text(&format!(
|
||||
"▸ graph: {} (start: {start_node})",
|
||||
self.graph_name
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn graph_complete(&self, end_node: &str, elapsed: Duration) {
|
||||
info!(
|
||||
"[graph:{}] end '{}' (elapsed {:?})",
|
||||
self.graph_name, end_node, elapsed
|
||||
);
|
||||
if !self.silent {
|
||||
eprintln!(
|
||||
"{}",
|
||||
dimmed_text(&format!("▸ graph done in {:.2}s", elapsed.as_secs_f64()))
|
||||
);
|
||||
}
|
||||
self.log_performance_summary();
|
||||
}
|
||||
|
||||
pub fn graph_error(&self, error: &anyhow::Error) {
|
||||
error!("[graph:{}] execution failed: {error:#}", self.graph_name);
|
||||
}
|
||||
|
||||
pub fn node_entry(&self, node: &Node, visit: usize) {
|
||||
debug!(
|
||||
"[graph:{}] entering '{}' (visit {visit})",
|
||||
self.graph_name, node.id
|
||||
);
|
||||
}
|
||||
|
||||
pub fn silent(&self) -> bool {
|
||||
self.silent
|
||||
}
|
||||
|
||||
pub fn node_start(&self, node: &Node, in_super_step: bool) {
|
||||
narrate_node_start(self.silent, node, in_super_step);
|
||||
}
|
||||
|
||||
pub fn super_step_start(&self, branches: &[String]) {
|
||||
if self.silent {
|
||||
return;
|
||||
}
|
||||
eprintln!(
|
||||
"{}",
|
||||
dimmed_text(&format!(
|
||||
"▸ {} super-step start: {}",
|
||||
ts(),
|
||||
branches.join(", ")
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
pub fn super_step_end(&self, targets: &[String]) {
|
||||
if self.silent {
|
||||
return;
|
||||
}
|
||||
let route = if targets.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" -> {}", targets.join(", "))
|
||||
};
|
||||
eprintln!(
|
||||
"{}",
|
||||
dimmed_text(&format!("▸ {} super-step end{route}", ts()))
|
||||
);
|
||||
}
|
||||
|
||||
pub fn record_timing(&mut self, node_id: &str, elapsed: Duration) {
|
||||
self.timings
|
||||
.entry(node_id.to_string())
|
||||
.or_default()
|
||||
.record(elapsed);
|
||||
}
|
||||
|
||||
pub fn routing(&self, from: &str, to: &str) {
|
||||
debug!("[graph:{}] {from} -> {to}", self.graph_name);
|
||||
}
|
||||
|
||||
pub fn validation_warning(&self, node_id: Option<&str>, message: &str) {
|
||||
match node_id {
|
||||
Some(id) => warn!("[graph:{}] [{id}] {message}", self.graph_name),
|
||||
None => warn!("[graph:{}] {message}", self.graph_name),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state_snapshot(&self, node_id: &str, state: &StateManager) {
|
||||
if !self.log_state_snapshots {
|
||||
return;
|
||||
}
|
||||
let snapshot = state.snapshot();
|
||||
let mut keys: Vec<&str> = snapshot.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
|
||||
debug!(
|
||||
"[graph:{}] [{node_id}] state: {} bytes, keys={:?}",
|
||||
self.graph_name,
|
||||
state.size_bytes(),
|
||||
keys
|
||||
);
|
||||
trace!(
|
||||
"[graph:{}] [{node_id}] full state: {:?}",
|
||||
self.graph_name, snapshot
|
||||
);
|
||||
}
|
||||
|
||||
fn log_performance_summary(&self) {
|
||||
if self.timings.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut rows: Vec<(&String, &NodeTiming)> = self.timings.iter().collect();
|
||||
rows.sort_by_key(|b| Reverse(b.1.total));
|
||||
|
||||
info!(
|
||||
"[graph:{}] performance summary (slowest first):",
|
||||
self.graph_name
|
||||
);
|
||||
|
||||
for (node_id, t) in rows {
|
||||
let avg = t.total / t.count.max(1) as u32;
|
||||
info!(
|
||||
"[graph:{}] {node_id}: {} visit(s), total {}ms, avg {}ms, max {}ms",
|
||||
self.graph_name,
|
||||
t.count,
|
||||
t.total.as_millis(),
|
||||
avg.as_millis(),
|
||||
t.max.as_millis(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn narrate_node_start(silent: bool, node: &Node, in_super_step: bool) {
|
||||
if silent {
|
||||
return;
|
||||
}
|
||||
let indent = if in_super_step { " " } else { "" };
|
||||
let label = node_type_label(node);
|
||||
eprintln!(
|
||||
"{}",
|
||||
dimmed_text(&format!("▸ {} {indent}{} ({label}) start", ts(), node.id))
|
||||
);
|
||||
}
|
||||
|
||||
pub fn narrate_node_complete(
|
||||
silent: bool,
|
||||
node: &Node,
|
||||
elapsed: Duration,
|
||||
next_target: Option<&str>,
|
||||
in_super_step: bool,
|
||||
) {
|
||||
if silent {
|
||||
return;
|
||||
}
|
||||
let indent = if in_super_step { " " } else { "" };
|
||||
let label = node_type_label(node);
|
||||
let dur = fmt_secs(elapsed);
|
||||
let route = next_target.map(|t| format!(" -> {t}")).unwrap_or_default();
|
||||
eprintln!(
|
||||
"{}",
|
||||
dimmed_text(&format!(
|
||||
"▸ {} {indent}{} ({label}) done in {dur}{route}",
|
||||
ts(),
|
||||
node.id
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
pub fn narrate_node_failed(
|
||||
silent: bool,
|
||||
node: &Node,
|
||||
elapsed: Duration,
|
||||
err: &str,
|
||||
in_super_step: bool,
|
||||
) {
|
||||
if silent {
|
||||
return;
|
||||
}
|
||||
let indent = if in_super_step { " " } else { "" };
|
||||
let label = node_type_label(node);
|
||||
let dur = fmt_secs(elapsed);
|
||||
let excerpt: String = err.chars().take(120).collect();
|
||||
eprintln!(
|
||||
"{}",
|
||||
dimmed_text(&format!(
|
||||
"▸ {} {indent}{} ({label}) FAILED in {dur} -- {excerpt}",
|
||||
ts(),
|
||||
node.id
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn node_type_label(node: &Node) -> &'static str {
|
||||
match &node.node_type {
|
||||
NodeType::Agent(_) => "agent",
|
||||
NodeType::Script(_) => "script",
|
||||
NodeType::Approval(_) => "approval",
|
||||
NodeType::Input(_) => "input",
|
||||
NodeType::Llm(_) => "llm",
|
||||
NodeType::Rag(_) => "rag",
|
||||
NodeType::End(_) => "end",
|
||||
NodeType::Map(_) => "map",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn records_and_aggregates_node_timings() {
|
||||
let mut logger = GraphLogger::with_visibility("g", false, false);
|
||||
logger.record_timing("a", Duration::from_millis(100));
|
||||
logger.record_timing("a", Duration::from_millis(300));
|
||||
logger.record_timing("b", Duration::from_millis(50));
|
||||
|
||||
let a = logger.timings.get("a").unwrap();
|
||||
assert_eq!(a.count, 2);
|
||||
assert_eq!(a.total, Duration::from_millis(400));
|
||||
assert_eq!(a.max, Duration::from_millis(300));
|
||||
|
||||
let b = logger.timings.get("b").unwrap();
|
||||
assert_eq!(b.count, 1);
|
||||
assert_eq!(b.total, Duration::from_millis(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_timing_max_tracks_largest() {
|
||||
let mut t = NodeTiming::default();
|
||||
|
||||
t.record(Duration::from_millis(10));
|
||||
t.record(Duration::from_millis(80));
|
||||
t.record(Duration::from_millis(40));
|
||||
|
||||
assert_eq!(t.max, Duration::from_millis(80));
|
||||
assert_eq!(t.count, 3);
|
||||
assert_eq!(t.total, Duration::from_millis(130));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_logger_has_no_timings() {
|
||||
let logger = GraphLogger::with_visibility("g", true, false);
|
||||
|
||||
assert!(logger.timings.is_empty());
|
||||
assert!(logger.log_state_snapshots);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use super::agent::AgentNodeExecutor;
|
||||
use super::executor::StepContext;
|
||||
use super::llm::LlmNodeExecutor;
|
||||
use super::rag::RagNodeExecutor;
|
||||
use super::state::StateManager;
|
||||
use super::types::{MapNode, NodeType};
|
||||
use crate::config::{RenderMode, RequestContext};
|
||||
use crate::graph::type_name;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use futures_util::future::join_all;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
pub(super) struct MapNodeExecutor;
|
||||
|
||||
impl MapNodeExecutor {
|
||||
pub(super) async fn execute(
|
||||
node: &MapNode,
|
||||
state: &mut StateManager,
|
||||
ctx: &mut RequestContext,
|
||||
step_ctx: &StepContext<'_>,
|
||||
node_id: &str,
|
||||
) -> Result<()> {
|
||||
let over_value = state
|
||||
.interpolate_raw(&node.over)
|
||||
.with_context(|| format!("map node '{node_id}': evaluating `over` template"))?;
|
||||
|
||||
let items = over_value.as_array().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"map node '{}': `over` template '{}' must resolve to an array, got {}",
|
||||
node_id,
|
||||
node.over,
|
||||
type_name(&over_value)
|
||||
)
|
||||
})?;
|
||||
let items = items.clone();
|
||||
|
||||
let branch_node = step_ctx
|
||||
.graph
|
||||
.get_node(&node.branch)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"map node '{node_id}': branch '{}' not found in graph",
|
||||
node.branch
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let max_conc = node
|
||||
.max_concurrency
|
||||
.unwrap_or(step_ctx.max_concurrency)
|
||||
.max(1);
|
||||
let semaphore = Arc::new(Semaphore::new(max_conc));
|
||||
let mut sub_tasks = Vec::with_capacity(items.len());
|
||||
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
let item = item.clone();
|
||||
let as_name = node.as_name.clone();
|
||||
let branch_clone = branch_node.clone();
|
||||
let mut sub_state = state.fork_for_branch_state();
|
||||
let mut sub_ctx = ctx.fork_for_branch();
|
||||
sub_ctx.render_mode = RenderMode::Silent;
|
||||
let script_clone = step_ctx.script_executor.clone();
|
||||
let sub_branch_id = node.branch.clone();
|
||||
let sem = semaphore.clone();
|
||||
let abort = step_ctx.abort_signal.clone();
|
||||
|
||||
sub_state.state_mut().set(as_name, item);
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let _permit = sem
|
||||
.acquire()
|
||||
.await
|
||||
.expect("map semaphore should not be closed");
|
||||
if abort.aborted() {
|
||||
return (
|
||||
idx,
|
||||
sub_state,
|
||||
Err(anyhow!("map sub-branch [{idx}] aborted")),
|
||||
);
|
||||
}
|
||||
let mut state = sub_state;
|
||||
let mut ctx = sub_ctx;
|
||||
|
||||
let exec_result: Result<()> = match &branch_clone.node_type {
|
||||
NodeType::Llm(n) => LlmNodeExecutor::execute(n, &mut state, &mut ctx)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
NodeType::Agent(n) => AgentNodeExecutor::execute(n, &mut state, &mut ctx)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
NodeType::Rag(n) => {
|
||||
RagNodeExecutor::execute(n, &sub_branch_id, &mut state, &mut ctx).await
|
||||
}
|
||||
NodeType::Script(n) => script_clone.execute(n, &mut state).await.map(|_| ()),
|
||||
_ => Err(anyhow!(
|
||||
"map branch '{}' has type that cannot run inside a map \
|
||||
(validator should have caught this; internal error)",
|
||||
branch_clone.id
|
||||
)),
|
||||
};
|
||||
|
||||
(idx, state, exec_result)
|
||||
});
|
||||
sub_tasks.push(task);
|
||||
}
|
||||
|
||||
let joined = join_all(sub_tasks).await;
|
||||
|
||||
// Collect outputs keyed by input index so order is preserved regardless of finish order.
|
||||
let mut outputs: HashMap<usize, Value> = HashMap::new();
|
||||
for join_result in joined {
|
||||
let (idx, sub_state, exec_result) =
|
||||
join_result.map_err(|e| anyhow!("map sub-branch panicked: {e}"))?;
|
||||
|
||||
exec_result
|
||||
.with_context(|| format!("map node '{node_id}': sub-branch [{idx}] failed"))?;
|
||||
|
||||
let output_value = sub_state
|
||||
.state()
|
||||
.get(&node.output_key)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"map node '{node_id}': sub-branch [{idx}] did not write \
|
||||
`output_key` '{}'",
|
||||
node.output_key
|
||||
)
|
||||
})?;
|
||||
|
||||
outputs.insert(idx, output_value);
|
||||
}
|
||||
|
||||
let mut collected = Vec::with_capacity(items.len());
|
||||
for idx in 0..items.len() {
|
||||
let value = outputs.remove(&idx).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"map node '{node_id}': internal error: missing result for sub-branch [{idx}]"
|
||||
)
|
||||
})?;
|
||||
collected.push(value);
|
||||
}
|
||||
|
||||
state
|
||||
.state_mut()
|
||||
.set(node.collect_into.clone(), Value::Array(collected));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
pub mod agent;
|
||||
pub mod dispatch;
|
||||
pub mod executor;
|
||||
pub mod llm;
|
||||
pub mod logging;
|
||||
pub mod map;
|
||||
pub mod parser;
|
||||
pub mod rag;
|
||||
pub mod reducer;
|
||||
pub mod script;
|
||||
pub mod staging;
|
||||
pub mod state;
|
||||
pub mod structured;
|
||||
pub mod types;
|
||||
pub mod user_interaction;
|
||||
pub mod validator;
|
||||
|
||||
pub use dispatch::{active_agent_graph_name, run_active_agent_graph};
|
||||
pub use executor::GraphExecutor;
|
||||
pub use parser::{GraphParser, agent_has_graph};
|
||||
use serde_json::Value;
|
||||
pub use types::{Graph, NodeType};
|
||||
|
||||
pub const GRAPH_SCHEMA_VERSION: &str = "1.0";
|
||||
|
||||
pub const DEFAULT_MAX_LOOP_ITERATIONS: usize = 100;
|
||||
|
||||
pub const MAX_STATE_SIZE_BYTES: usize = 32 * 1024;
|
||||
|
||||
pub(in crate::graph) fn type_name(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "bool",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
use super::types::Graph;
|
||||
use crate::config::paths;
|
||||
use anyhow::{Context, Error, Result, anyhow, bail};
|
||||
use std::fs::read_to_string;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const SUPPORTED_VERSIONS: &[&str] = &["1.0"];
|
||||
|
||||
pub struct GraphParser {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl GraphParser {
|
||||
pub fn new(base_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
base_dir: base_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_from_file(&self, path: impl AsRef<Path>) -> Result<Graph> {
|
||||
let path = path.as_ref();
|
||||
let full_path = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
self.base_dir.join(path)
|
||||
};
|
||||
|
||||
let contents = read_to_string(&full_path)
|
||||
.with_context(|| format!("Failed to read graph file at '{}'", full_path.display()))?;
|
||||
|
||||
self.load_from_string(&contents)
|
||||
.with_context(|| format!("Failed to parse graph file at '{}'", full_path.display()))
|
||||
}
|
||||
|
||||
pub fn load_from_string(&self, yaml: &str) -> Result<Graph> {
|
||||
let mut graph: Graph = serde_yaml::from_str(yaml).map_err(enhance_yaml_error)?;
|
||||
|
||||
validate_schema_version(&graph.version)?;
|
||||
|
||||
for (key, node) in &mut graph.nodes {
|
||||
if node.id.is_empty() {
|
||||
node.id = key.clone();
|
||||
} else if &node.id != key {
|
||||
bail!(
|
||||
"Node ID mismatch: key '{}' does not match node.id '{}'",
|
||||
key,
|
||||
node.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
validate_structure(&graph)?;
|
||||
|
||||
Ok(graph)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_schema_version(version: &str) -> Result<()> {
|
||||
if !SUPPORTED_VERSIONS.contains(&version) {
|
||||
bail!(
|
||||
"Unsupported graph schema version '{}'. Supported versions: {}",
|
||||
version,
|
||||
SUPPORTED_VERSIONS.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_structure(graph: &Graph) -> Result<()> {
|
||||
if graph.name.is_empty() {
|
||||
bail!("Graph must have a non-empty 'name' field");
|
||||
}
|
||||
|
||||
if graph.nodes.is_empty() {
|
||||
bail!("Graph '{}' has no nodes defined", graph.name);
|
||||
}
|
||||
|
||||
if !graph.has_node(&graph.start) {
|
||||
bail!(
|
||||
"Start node '{}' not found in graph '{}'. Available nodes: {}",
|
||||
graph.start,
|
||||
graph.name,
|
||||
graph.node_ids().join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enhance_yaml_error(error: serde_yaml::Error) -> Error {
|
||||
let msg = error.to_string();
|
||||
|
||||
let hint = if msg.contains("missing field") {
|
||||
"\n\nHint: Check that all required fields are present.\n\
|
||||
Top-level required fields: `name`, `start`, `nodes`.\n\
|
||||
Each node requires `type` plus that type's fields:\n\
|
||||
- agent: `agent`, `prompt`\n\
|
||||
- script: `script`\n\
|
||||
- approval: `question`, `options`, `routes`, `on_other`\n\
|
||||
- input: `question`\n\
|
||||
- llm: `prompt`\n\
|
||||
- rag: `documents`\n\
|
||||
- end: (no required fields)"
|
||||
} else if msg.contains("unknown field") || msg.contains("unknown variant") {
|
||||
"\n\nHint: Check for typos in field names or `type:` values.\n\
|
||||
Valid node types: agent, script, approval, input, llm, rag, end."
|
||||
} else if msg.contains("invalid type") {
|
||||
"\n\nHint: Check that field values have the correct type.\n\
|
||||
- Strings should be quoted if they contain special characters\n\
|
||||
- Numbers should not be quoted\n\
|
||||
- Lists use YAML array syntax (- item1)\n\
|
||||
- Maps use YAML object syntax (key: value)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
anyhow!("YAML parsing error: {}{}", msg, hint)
|
||||
}
|
||||
|
||||
pub fn agent_has_graph(agent_name: &str) -> bool {
|
||||
paths::agent_graph_file(agent_name).exists()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::GRAPH_SCHEMA_VERSION;
|
||||
use super::super::types::NodeType;
|
||||
use super::*;
|
||||
use indoc::formatdoc;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::{env, fs, process};
|
||||
|
||||
fn parser() -> GraphParser {
|
||||
GraphParser::new(env::current_dir().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_simple_graph() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: simple_graph
|
||||
version: "1.0"
|
||||
start: node1
|
||||
nodes:
|
||||
node1:
|
||||
id: node1
|
||||
type: agent
|
||||
agent: test_agent
|
||||
prompt: "Hello world"
|
||||
next: node2
|
||||
node2:
|
||||
id: node2
|
||||
type: end
|
||||
output: done
|
||||
"#};
|
||||
|
||||
let graph = parser().load_from_string(&yaml).unwrap();
|
||||
|
||||
assert_eq!(graph.name, "simple_graph");
|
||||
assert_eq!(graph.start, "node1");
|
||||
assert_eq!(graph.nodes.len(), 2);
|
||||
assert_eq!(
|
||||
graph.nodes.get("node1").unwrap().next_target(),
|
||||
Some("node2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_fills_node_ids_from_keys() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: auto_id_graph
|
||||
version: "1.0"
|
||||
start: node1
|
||||
nodes:
|
||||
node1:
|
||||
type: agent
|
||||
agent: test_agent
|
||||
prompt: Test
|
||||
next: node2
|
||||
node2:
|
||||
type: end
|
||||
output: done
|
||||
"#};
|
||||
|
||||
let graph = parser().load_from_string(&yaml).unwrap();
|
||||
|
||||
assert_eq!(graph.nodes.get("node1").unwrap().id, "node1");
|
||||
assert_eq!(graph.nodes.get("node2").unwrap().id, "node2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_start_node() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: bad_graph
|
||||
version: "1.0"
|
||||
start: nonexistent
|
||||
nodes:
|
||||
node1:
|
||||
type: end
|
||||
"#};
|
||||
|
||||
let err = parser().load_from_string(&yaml).unwrap_err().to_string();
|
||||
|
||||
assert!(
|
||||
err.contains("Start node 'nonexistent' not found"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_graph_name() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: ""
|
||||
version: "1.0"
|
||||
start: node1
|
||||
nodes:
|
||||
node1:
|
||||
type: end
|
||||
"#};
|
||||
|
||||
let err = parser().load_from_string(&yaml).unwrap_err().to_string();
|
||||
|
||||
assert!(err.contains("non-empty 'name'"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_no_nodes() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: empty_graph
|
||||
version: "1.0"
|
||||
start: node1
|
||||
nodes: {}
|
||||
"#, "{}"};
|
||||
|
||||
let err = parser().load_from_string(&yaml).unwrap_err().to_string();
|
||||
|
||||
assert!(err.contains("no nodes defined"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_version() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: future_graph
|
||||
version: "2.0"
|
||||
start: node1
|
||||
nodes:
|
||||
node1:
|
||||
type: end
|
||||
"#};
|
||||
|
||||
let err = parser().load_from_string(&yaml).unwrap_err().to_string();
|
||||
|
||||
assert!(
|
||||
err.contains("Unsupported graph schema version"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_node_id_mismatch() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: mismatch_graph
|
||||
version: "1.0"
|
||||
start: node1
|
||||
nodes:
|
||||
node1:
|
||||
id: different_id
|
||||
type: end
|
||||
"#};
|
||||
|
||||
let err = parser().load_from_string(&yaml).unwrap_err().to_string();
|
||||
|
||||
assert!(err.contains("Node ID mismatch"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_approval_node_with_routes() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: approval_graph
|
||||
version: "1.0"
|
||||
start: approval1
|
||||
nodes:
|
||||
approval1:
|
||||
type: approval
|
||||
question: "Proceed with deployment?"
|
||||
options:
|
||||
- "Yes"
|
||||
- "No"
|
||||
routes:
|
||||
"Yes": deploy
|
||||
"No": cancel
|
||||
on_other: cancel
|
||||
deploy:
|
||||
type: end
|
||||
cancel:
|
||||
type: end
|
||||
"#};
|
||||
|
||||
let graph = parser().load_from_string(&yaml).unwrap();
|
||||
|
||||
let approval = graph.nodes.get("approval1").unwrap();
|
||||
match &approval.node_type {
|
||||
NodeType::Approval(a) => {
|
||||
assert_eq!(a.options.len(), 2);
|
||||
assert_eq!(a.routes.len(), 2);
|
||||
assert_eq!(a.routes.get("Yes").map(|s| s.as_str()), Some("deploy"));
|
||||
}
|
||||
_ => panic!("expected approval node"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_settings_overrides() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: settings_graph
|
||||
version: "1.0"
|
||||
start: node1
|
||||
settings:
|
||||
max_loop_iterations: 50
|
||||
timeout: 300
|
||||
log_state_snapshots: false
|
||||
nodes:
|
||||
node1:
|
||||
type: end
|
||||
"#};
|
||||
|
||||
let graph = parser().load_from_string(&yaml).unwrap();
|
||||
|
||||
assert_eq!(graph.settings.max_loop_iterations, 50);
|
||||
assert_eq!(graph.settings.timeout, Some(300));
|
||||
assert!(!graph.settings.log_state_snapshots);
|
||||
assert!(graph.settings.validate_before_run);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_initial_state() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: state_graph
|
||||
version: "1.0"
|
||||
start: node1
|
||||
initial_state:
|
||||
user_name: "Alice"
|
||||
count: 42
|
||||
enabled: true
|
||||
nodes:
|
||||
node1:
|
||||
type: end
|
||||
"#};
|
||||
|
||||
let graph = parser().load_from_string(&yaml).unwrap();
|
||||
|
||||
assert_eq!(graph.initial_state.len(), 3);
|
||||
assert_eq!(graph.initial_state.get("user_name").unwrap(), "Alice");
|
||||
assert_eq!(
|
||||
graph.initial_state.get("count").unwrap(),
|
||||
&serde_json::json!(42)
|
||||
);
|
||||
assert_eq!(
|
||||
graph.initial_state.get("enabled").unwrap(),
|
||||
&serde_json::json!(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_default_version_when_absent() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: no_version
|
||||
start: node1
|
||||
nodes:
|
||||
node1:
|
||||
type: end
|
||||
"#};
|
||||
|
||||
let graph = parser().load_from_string(&yaml).unwrap();
|
||||
|
||||
assert_eq!(graph.version, GRAPH_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_node_type_with_hint() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: bad_type
|
||||
version: "1.0"
|
||||
start: node1
|
||||
nodes:
|
||||
node1:
|
||||
type: nonsense
|
||||
"#};
|
||||
|
||||
let err = parser().load_from_string(&yaml).unwrap_err().to_string();
|
||||
|
||||
assert!(
|
||||
err.contains("Valid node types") || err.contains("unknown variant"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_yaml() {
|
||||
let yaml = "name: bad\n bad: indent\nstart: a";
|
||||
|
||||
let result = parser().load_from_string(yaml);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_fields_have_a_hint() {
|
||||
let yaml = formatdoc! {r#"
|
||||
name: missing_start
|
||||
version: "1.0"
|
||||
nodes:
|
||||
node1:
|
||||
type: end
|
||||
"#};
|
||||
|
||||
let err = parser().load_from_string(&yaml).unwrap_err().to_string();
|
||||
|
||||
assert!(err.contains("Hint"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_file_reads_disk() {
|
||||
let dir = env::temp_dir();
|
||||
let path = dir.join(format!("coyote_graph_parser_test_{}.yaml", process::id()));
|
||||
let yaml = formatdoc! {r#"
|
||||
name: disk_graph
|
||||
version: "1.0"
|
||||
start: only
|
||||
nodes:
|
||||
only:
|
||||
type: end
|
||||
output: ok
|
||||
"#};
|
||||
{
|
||||
let mut f = File::create(&path).unwrap();
|
||||
f.write_all(yaml.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
let graph = GraphParser::new(dir).load_from_file(&path).unwrap();
|
||||
|
||||
assert_eq!(graph.name, "disk_graph");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_file_errors_on_missing_path() {
|
||||
let err = parser()
|
||||
.load_from_file("/definitely/not/a/real/path/to_any_graph.yaml")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("Failed to read graph file"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_has_graph_false_for_unknown_agent() {
|
||||
assert!(!agent_has_graph("__nonexistent_agent_for_test__"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use super::state::StateManager;
|
||||
use super::types::RagNode;
|
||||
use crate::config::RequestContext;
|
||||
use crate::utils::create_abort_signal;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde_json::{Map, Value};
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const OUTPUT_KEY: &str = "output";
|
||||
const DEFAULT_QUERY: &str = "{{initial_prompt}}";
|
||||
const DEFAULT_RAG_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
pub struct RagNodeExecutor;
|
||||
|
||||
impl RagNodeExecutor {
|
||||
pub(super) async fn execute(
|
||||
node: &RagNode,
|
||||
node_id: &str,
|
||||
state_manager: &mut StateManager,
|
||||
ctx: &mut RequestContext,
|
||||
) -> Result<()> {
|
||||
let query_template = node.query.as_deref().unwrap_or(DEFAULT_QUERY);
|
||||
let query = state_manager
|
||||
.interpolate(query_template)
|
||||
.context("Failed to interpolate rag node query")?;
|
||||
|
||||
let rag = ctx
|
||||
.agent
|
||||
.as_ref()
|
||||
.and_then(|a| a.graph_rag(node_id))
|
||||
.ok_or_else(|| anyhow!("rag node '{node_id}' has no initialized knowledge base"))?;
|
||||
|
||||
let top_k = node.top_k.unwrap_or_else(|| rag.configured_top_k());
|
||||
let rerank = rag.configured_reranker();
|
||||
|
||||
let timeout_dur = Duration::from_secs(node.timeout.unwrap_or(DEFAULT_RAG_TIMEOUT_SECS));
|
||||
let abort = create_abort_signal();
|
||||
let (context, sources_str, _ids) =
|
||||
timeout(timeout_dur, rag.search(&query, top_k, rerank, abort))
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"rag node '{node_id}' timed out after {}s",
|
||||
timeout_dur.as_secs()
|
||||
)
|
||||
})?
|
||||
.with_context(|| format!("rag node '{node_id}' retrieval failed"))?;
|
||||
|
||||
let output = build_rag_output(context, &sources_str);
|
||||
apply_state_updates(node, state_manager, &output);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble the `{{output}}` value as `{ "context": <ctx>, "sources": [...] }`.
|
||||
fn build_rag_output(context: String, sources_str: &str) -> Value {
|
||||
let sources: Vec<Value> = sources_str
|
||||
.lines()
|
||||
.map(|line| line.trim().trim_start_matches("- ").trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| Value::String(s.to_string()))
|
||||
.collect();
|
||||
let mut obj = Map::new();
|
||||
|
||||
obj.insert("context".into(), Value::String(context));
|
||||
obj.insert("sources".into(), Value::Array(sources));
|
||||
|
||||
Value::Object(obj)
|
||||
}
|
||||
|
||||
fn apply_state_updates(node: &RagNode, state_manager: &mut StateManager, output: &Value) {
|
||||
let Some(updates) = &node.state_updates else {
|
||||
return;
|
||||
};
|
||||
let prev_output = state_manager.state().get(OUTPUT_KEY).cloned();
|
||||
state_manager
|
||||
.state_mut()
|
||||
.set(OUTPUT_KEY.into(), output.clone());
|
||||
|
||||
for (key, template) in updates {
|
||||
let value = state_manager.interpolate_lenient(template);
|
||||
state_manager
|
||||
.state_mut()
|
||||
.set(key.clone(), Value::String(value));
|
||||
}
|
||||
|
||||
match prev_output {
|
||||
Some(v) => state_manager.state_mut().set(OUTPUT_KEY.into(), v),
|
||||
None => state_manager
|
||||
.state_mut()
|
||||
.set(OUTPUT_KEY.into(), Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn build_rag_output_splits_bullet_sources_into_array() {
|
||||
let out = build_rag_output("ctx".into(), "- a.md\n- https://x.com/spec");
|
||||
|
||||
assert_eq!(out["context"], json!("ctx"));
|
||||
assert_eq!(out["sources"], json!(["a.md", "https://x.com/spec"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_rag_output_handles_empty_sources() {
|
||||
let out = build_rag_output("ctx".into(), "");
|
||||
|
||||
assert_eq!(out["sources"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_rag_output_ignores_blank_lines() {
|
||||
let out = build_rag_output("c".into(), "- a\n\n- b\n");
|
||||
|
||||
assert_eq!(out["sources"], json!(["a", "b"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_rag_output_tolerates_unprefixed_lines() {
|
||||
let out = build_rag_output("c".into(), "plain/path");
|
||||
|
||||
assert_eq!(out["sources"], json!(["plain/path"]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use super::types::Reducer;
|
||||
use crate::graph::type_name;
|
||||
use anyhow::{Result, bail};
|
||||
use serde_json::{Number, Value};
|
||||
|
||||
pub fn apply(reducer: Reducer, current: Option<&Value>, incoming: Value) -> Result<Value> {
|
||||
match reducer {
|
||||
Reducer::Append => apply_append(current, incoming),
|
||||
Reducer::Extend => apply_extend(current, incoming),
|
||||
Reducer::Concat => apply_concat(current, incoming),
|
||||
Reducer::Sum => apply_sum(current, incoming),
|
||||
Reducer::Max => apply_max(current, incoming),
|
||||
Reducer::Min => apply_min(current, incoming),
|
||||
Reducer::Merge => apply_merge(current, incoming),
|
||||
Reducer::Overwrite => Ok(incoming),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_append(current: Option<&Value>, incoming: Value) -> Result<Value> {
|
||||
let mut arr = match current {
|
||||
None => Vec::new(),
|
||||
Some(Value::Array(a)) => a.clone(),
|
||||
Some(other) => bail!(
|
||||
"reducer 'append' requires an array (or absent) for the current value, got {}",
|
||||
type_name(other)
|
||||
),
|
||||
};
|
||||
arr.push(incoming);
|
||||
|
||||
Ok(Value::Array(arr))
|
||||
}
|
||||
|
||||
fn apply_extend(current: Option<&Value>, incoming: Value) -> Result<Value> {
|
||||
let mut arr = match current {
|
||||
None => Vec::new(),
|
||||
Some(Value::Array(a)) => a.clone(),
|
||||
Some(other) => bail!(
|
||||
"reducer 'extend' requires an array (or absent) for the current value, got {}",
|
||||
type_name(other)
|
||||
),
|
||||
};
|
||||
match incoming {
|
||||
Value::Array(items) => arr.extend(items),
|
||||
other => bail!(
|
||||
"reducer 'extend' requires an array for the incoming value, got {}",
|
||||
type_name(&other)
|
||||
),
|
||||
}
|
||||
|
||||
Ok(Value::Array(arr))
|
||||
}
|
||||
|
||||
fn apply_concat(current: Option<&Value>, incoming: Value) -> Result<Value> {
|
||||
let incoming_str = match incoming {
|
||||
Value::String(s) => s,
|
||||
other => bail!(
|
||||
"reducer 'concat' requires a string for the incoming value, got {}",
|
||||
type_name(&other)
|
||||
),
|
||||
};
|
||||
let result = match current {
|
||||
None => incoming_str,
|
||||
Some(Value::String(c)) => {
|
||||
if c.is_empty() {
|
||||
incoming_str
|
||||
} else {
|
||||
format!("{c}\n{incoming_str}")
|
||||
}
|
||||
}
|
||||
Some(other) => bail!(
|
||||
"reducer 'concat' requires a string (or absent) for the current value, got {}",
|
||||
type_name(other)
|
||||
),
|
||||
};
|
||||
|
||||
Ok(Value::String(result))
|
||||
}
|
||||
|
||||
fn apply_sum(current: Option<&Value>, incoming: Value) -> Result<Value> {
|
||||
let i = number_or_error(&incoming, "sum", "incoming")?;
|
||||
let c = match current {
|
||||
None => 0.0,
|
||||
Some(value) => number_or_error(value, "sum", "current")?,
|
||||
};
|
||||
|
||||
Ok(json_number(c + i))
|
||||
}
|
||||
|
||||
fn apply_max(current: Option<&Value>, incoming: Value) -> Result<Value> {
|
||||
let i = number_or_error(&incoming, "max", "incoming")?;
|
||||
match current {
|
||||
None => Ok(json_number(i)),
|
||||
Some(value) => {
|
||||
let c = number_or_error(value, "max", "current")?;
|
||||
Ok(json_number(c.max(i)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_min(current: Option<&Value>, incoming: Value) -> Result<Value> {
|
||||
let i = number_or_error(&incoming, "min", "incoming")?;
|
||||
match current {
|
||||
None => Ok(json_number(i)),
|
||||
Some(value) => {
|
||||
let c = number_or_error(value, "min", "current")?;
|
||||
Ok(json_number(c.min(i)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_merge(current: Option<&Value>, incoming: Value) -> Result<Value> {
|
||||
let mut map = match current {
|
||||
None => serde_json::Map::new(),
|
||||
Some(Value::Object(m)) => m.clone(),
|
||||
Some(other) => bail!(
|
||||
"reducer 'merge' requires an object (or absent) for the current value, got {}",
|
||||
type_name(other)
|
||||
),
|
||||
};
|
||||
match incoming {
|
||||
Value::Object(items) => {
|
||||
for (k, v) in items {
|
||||
map.insert(k, v);
|
||||
}
|
||||
}
|
||||
other => bail!(
|
||||
"reducer 'merge' requires an object for the incoming value, got {}",
|
||||
type_name(&other)
|
||||
),
|
||||
}
|
||||
|
||||
Ok(Value::Object(map))
|
||||
}
|
||||
|
||||
fn number_or_error(value: &Value, reducer_name: &str, position: &str) -> Result<f64> {
|
||||
match value.as_f64() {
|
||||
Some(n) => Ok(n),
|
||||
None => bail!(
|
||||
"reducer '{reducer_name}' requires a number for the {position} value, got {}",
|
||||
type_name(value)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric reducers compute in f64 for simplicity. Integer typing is preserved when the result is losslessly
|
||||
// representable as i64.
|
||||
fn json_number(n: f64) -> Value {
|
||||
if n.fract() == 0.0 && n.is_finite() && n.abs() <= (i64::MAX as f64) {
|
||||
Value::Number(Number::from(n as i64))
|
||||
} else {
|
||||
match Number::from_f64(n) {
|
||||
Some(num) => Value::Number(num),
|
||||
None => Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn append_to_absent_creates_single_element_array() {
|
||||
let result = apply(Reducer::Append, None, json!("a")).unwrap();
|
||||
|
||||
assert_eq!(result, json!(["a"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_pushes_onto_existing_array() {
|
||||
let current = json!(["a", "b"]);
|
||||
let result = apply(Reducer::Append, Some(¤t), json!("c")).unwrap();
|
||||
|
||||
assert_eq!(result, json!(["a", "b", "c"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_errors_when_current_is_not_array() {
|
||||
let current = json!("not an array");
|
||||
|
||||
let err = apply(Reducer::Append, Some(¤t), json!("x"))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("'append'"), "got: {err}");
|
||||
assert!(err.contains("string"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_concatenates_arrays() {
|
||||
let current = json!([1, 2]);
|
||||
|
||||
let result = apply(Reducer::Extend, Some(¤t), json!([3, 4])).unwrap();
|
||||
|
||||
assert_eq!(result, json!([1, 2, 3, 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_from_absent_with_array() {
|
||||
let result = apply(Reducer::Extend, None, json!([1, 2])).unwrap();
|
||||
|
||||
assert_eq!(result, json!([1, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_errors_when_incoming_is_not_array() {
|
||||
let err = apply(Reducer::Extend, None, json!(42))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("'extend'"), "got: {err}");
|
||||
assert!(err.contains("number"), "got: {err}");
|
||||
assert!(err.contains("incoming"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concat_joins_strings_with_newline() {
|
||||
let current = json!("first");
|
||||
|
||||
let result = apply(Reducer::Concat, Some(¤t), json!("second")).unwrap();
|
||||
|
||||
assert_eq!(result, json!("first\nsecond"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concat_from_absent_yields_incoming() {
|
||||
let result = apply(Reducer::Concat, None, json!("hello")).unwrap();
|
||||
|
||||
assert_eq!(result, json!("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concat_skips_separator_when_current_is_empty_string() {
|
||||
let current = json!("");
|
||||
|
||||
let result = apply(Reducer::Concat, Some(¤t), json!("first")).unwrap();
|
||||
|
||||
assert_eq!(result, json!("first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concat_errors_when_incoming_is_not_string() {
|
||||
let err = apply(Reducer::Concat, None, json!(42))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("'concat'"), "got: {err}");
|
||||
assert!(err.contains("number"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_adds_numbers() {
|
||||
let current = json!(5);
|
||||
|
||||
let result = apply(Reducer::Sum, Some(¤t), json!(7)).unwrap();
|
||||
|
||||
assert_eq!(result, json!(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_starts_from_zero_when_current_absent() {
|
||||
let result = apply(Reducer::Sum, None, json!(3.5)).unwrap();
|
||||
|
||||
assert_eq!(result, json!(3.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_preserves_integer_type_for_whole_results() {
|
||||
let current = json!(2);
|
||||
|
||||
let result = apply(Reducer::Sum, Some(¤t), json!(3)).unwrap();
|
||||
|
||||
assert!(result.is_i64(), "expected integer, got {result:?}");
|
||||
assert_eq!(result, json!(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_uses_float_when_result_has_fractional() {
|
||||
let current = json!(1.5);
|
||||
let result = apply(Reducer::Sum, Some(¤t), json!(2.25)).unwrap();
|
||||
|
||||
assert_eq!(result, json!(3.75));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_errors_on_string_incoming() {
|
||||
let err = apply(Reducer::Sum, None, json!("not a number"))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("'sum'"), "got: {err}");
|
||||
assert!(err.contains("string"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_returns_larger_of_two() {
|
||||
let current = json!(5);
|
||||
let result = apply(Reducer::Max, Some(¤t), json!(3)).unwrap();
|
||||
assert_eq!(result, json!(5));
|
||||
|
||||
let result = apply(Reducer::Max, Some(¤t), json!(10)).unwrap();
|
||||
assert_eq!(result, json!(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_yields_incoming_when_current_absent() {
|
||||
let result = apply(Reducer::Max, None, json!(42)).unwrap();
|
||||
|
||||
assert_eq!(result, json!(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_returns_smaller_of_two() {
|
||||
let current = json!(5);
|
||||
let result = apply(Reducer::Min, Some(¤t), json!(3)).unwrap();
|
||||
assert_eq!(result, json!(3));
|
||||
|
||||
let result = apply(Reducer::Min, Some(¤t), json!(10)).unwrap();
|
||||
assert_eq!(result, json!(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_errors_on_non_numeric_current() {
|
||||
let current = json!("oops");
|
||||
|
||||
let err = apply(Reducer::Min, Some(¤t), json!(1))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("'min'"), "got: {err}");
|
||||
assert!(err.contains("current"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_unions_objects_with_incoming_winning_collisions() {
|
||||
let current = json!({ "a": 1, "b": 2 });
|
||||
let incoming = json!({ "b": 99, "c": 3 });
|
||||
|
||||
let result = apply(Reducer::Merge, Some(¤t), incoming).unwrap();
|
||||
|
||||
assert_eq!(result, json!({ "a": 1, "b": 99, "c": 3 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_from_absent_yields_incoming_object() {
|
||||
let result = apply(Reducer::Merge, None, json!({ "k": "v" })).unwrap();
|
||||
|
||||
assert_eq!(result, json!({ "k": "v" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_errors_when_incoming_is_not_object() {
|
||||
let err = apply(Reducer::Merge, None, json!([1, 2]))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("'merge'"), "got: {err}");
|
||||
assert!(err.contains("array"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_errors_when_current_is_not_object() {
|
||||
let current = json!("not object");
|
||||
|
||||
let err = apply(Reducer::Merge, Some(¤t), json!({ "k": "v" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("'merge'"), "got: {err}");
|
||||
assert!(err.contains("current"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_ignores_current_and_returns_incoming() {
|
||||
let current = json!("old");
|
||||
|
||||
let result = apply(Reducer::Overwrite, Some(¤t), json!("new")).unwrap();
|
||||
|
||||
assert_eq!(result, json!("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_works_with_absent_current() {
|
||||
let result = apply(Reducer::Overwrite, None, json!(42)).unwrap();
|
||||
|
||||
assert_eq!(result, json!(42));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user