Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff3419a714 | |||
|
a5899da4fb
|
|||
|
dedcef8ac5
|
|||
|
d658f1d2fe
|
|||
|
6b4a45874f
|
|||
|
7839e1dbd9
|
|||
|
78c3932f36
|
|||
|
11334149b0
|
|||
|
4caa035528
|
|||
|
f30e81af08
|
|||
|
4c75655f58
|
|||
|
f865892c28
|
|||
|
ebeb9c9b7d
|
|||
|
ab2b927fcb
|
|||
|
7e5ff2ba1f
|
|||
|
ed59051f3d
|
|||
|
|
e98bf56a2b | ||
|
|
fb510b1a4f | ||
|
6c17462040
|
|||
|
1536cf384c
|
|||
|
d6842d7e29
|
|||
|
fbc0acda2a
|
|||
|
0327d041b6
|
|||
|
6a01fd4fbd
|
|||
| d822180205 | |||
|
89d0fdce26
|
|||
|
b3ecdce979
|
@@ -1,3 +1,74 @@
|
|||||||
|
## v0.3.0 (2026-04-02)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Added `todo__clear` function to the todo system and updated REPL commands to have a .clear todo as well for significant changes in agent direction
|
||||||
|
- Added available tools to prompts for sisyphus and code-reviewer agent families
|
||||||
|
- Added available tools to coder prompt
|
||||||
|
- Improved token efficiency when delegating from sisyphus -> coder
|
||||||
|
- modified sisyphus agents to use the new ddg-search MCP server for web searches instead of built-in model searches
|
||||||
|
- Added support for specifying a custom response to multiple-choice prompts when nothing suits the user's needs
|
||||||
|
- Supported theming in the inquire prompts in the REPL
|
||||||
|
- Added the duckduckgo-search MCP server for searching the web (in addition to the built-in tools for web searches)
|
||||||
|
- Support for Gemini OAuth
|
||||||
|
- Support authenticating or refreshing OAuth for supported clients from within the REPL
|
||||||
|
- Allow first-runs to select OAuth for supported providers
|
||||||
|
- Support OAuth authentication flows for Claude
|
||||||
|
- Improved MCP server spinup and spindown when switching contexts or settings in the REPL: Modify existing config rather than stopping all servers always and re-initializing if unnecessary
|
||||||
|
- Allow the explore agent to run search queries for understanding docs or API specs
|
||||||
|
- Allow the oracle to perform web searches for deeper research
|
||||||
|
- Added web search support to the main sisyphus agent to answer user queries
|
||||||
|
- 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
|
||||||
|
- 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
|
||||||
|
- Full passive task queue integration for parallelization of subagents
|
||||||
|
- Implemented initial scaffolding for built-in sub-agent spawning tool call operations
|
||||||
|
- Initial models for agent parallelization
|
||||||
|
- Added interactive prompting between the LLM and the user in Sisyphus using the built-in Bash utils scripts
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Clarified user text input interaction
|
||||||
|
- recursion bug with similarly named Bash search functions in the explore agent
|
||||||
|
- updated the error for unauthenticated oauth to include the REPL .authenticated command
|
||||||
|
- Corrected a bug in the coder agent that wasn't outputting a summary of the changes made, so the parent Sisyphus agent has no idea if the agent worked or not
|
||||||
|
- Claude code system prompt injected into claude requests to make them valid once again
|
||||||
|
- Do not inject tools when models don't support them; detect this conflict before API calls happen
|
||||||
|
- The REPL .authenticate command works from within sessions, agents, and roles with pre-configured models
|
||||||
|
- Implemented the path normalization fix for the oracle and explore agents
|
||||||
|
- Updated the atlassian MCP server endpoint to account for future deprecation
|
||||||
|
- Fixed a bug in the coder agent that was causing the agent to create absolute paths from the current directory
|
||||||
|
- the updated regex for secrets injection broke MCP server secrets interpolation because the regex greedily matched on new lines, replacing too much content. This fix just ignores commented out lines in YAML files by skipping commented out lines.
|
||||||
|
- Don't try to inject secrets into commented-out lines in the config
|
||||||
|
- Removed top_p parameter from some agents so they can work across model providers
|
||||||
|
- Improved sub-agent stdout and stderr output for users to follow
|
||||||
|
- Inject agent variables into environment variables for global tool calls when invoked from agents to modify global tool behavior
|
||||||
|
- Removed the unnecessary execute_commands tool from the oracle agent
|
||||||
|
- Added auto_confirm to the coder agent so sub-agent spawning doesn't freeze
|
||||||
|
- Fixed a bug in the new supervisor and todo built-ins that was causing errors with OpenAI models
|
||||||
|
- Added condition to sisyphus to always output a summary to clearly indicate completion
|
||||||
|
- Updated the sisyphus prompt to explicitly tell it to delegate to the coder agent when it wants to write any code at all except for trivial changes
|
||||||
|
- Added back in the auto_confirm variable into sisyphus
|
||||||
|
- Removed the now unnecessary is_stale_response that was breaking auto-continuing with parallel agents
|
||||||
|
- Bypassed enabled_tools for user interaction tools so if function calling is enabled at all, the LLM has access to the user interaction tools when in REPL mode
|
||||||
|
- When parallel agents run, only write to stdout from the parent and only display the parent's throbber
|
||||||
|
- Forgot to implement support for failing a task and keep all dependents blocked
|
||||||
|
- Clean up orphaned sub-agents when the parent agent
|
||||||
|
- Fixed the bash prompt utils so that they correctly show output when being run by a tool invocation
|
||||||
|
- Forgot to automatically add the bidirectional communication back up to parent agents from sub-agents (i.e. need to be able to check inbox and send messages)
|
||||||
|
- Agent delegation tools were not being passed into the {{__tools__}} placeholder so agents weren't delegating to subagents
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Made the oauth module more generic so it can support loopback OAuth (not just manual)
|
||||||
|
- Changed the default session name for Sisyphus to temp (to require users to explicitly name sessions they wish to save)
|
||||||
|
- Updated the sisyphus agent to use the built-in user interaction tools instead of custom bash-based tools
|
||||||
|
- Cleaned up some left-over implementation stubs
|
||||||
|
|
||||||
## v0.2.0 (2026-02-14)
|
## v0.2.0 (2026-02-14)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|||||||
Generated
+499
-872
File diff suppressed because it is too large
Load Diff
+10
-7
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "loki-ai"
|
name = "loki-ai"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
||||||
description = "An all-in-one, batteries included LLM CLI Tool"
|
description = "An all-in-one, batteries included LLM CLI Tool"
|
||||||
@@ -18,10 +18,11 @@ anyhow = "1.0.69"
|
|||||||
bytes = "1.4.0"
|
bytes = "1.4.0"
|
||||||
clap = { version = "4.5.40", features = ["cargo", "derive", "wrap_help"] }
|
clap = { version = "4.5.40", features = ["cargo", "derive", "wrap_help"] }
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
|
dunce = "1.0.5"
|
||||||
futures-util = "0.3.29"
|
futures-util = "0.3.29"
|
||||||
inquire = "0.9.4"
|
inquire = "0.9.4"
|
||||||
is-terminal = "0.4.9"
|
is-terminal = "0.4.9"
|
||||||
reedline = "0.40.0"
|
reedline = "0.46.0"
|
||||||
serde = { version = "1.0.152", features = ["derive"] }
|
serde = { version = "1.0.152", features = ["derive"] }
|
||||||
serde_json = { version = "1.0.93", features = ["preserve_order"] }
|
serde_json = { version = "1.0.93", features = ["preserve_order"] }
|
||||||
serde_yaml = "0.9.17"
|
serde_yaml = "0.9.17"
|
||||||
@@ -37,7 +38,7 @@ tokio-graceful = "0.2.2"
|
|||||||
tokio-stream = { version = "0.1.15", default-features = false, features = [
|
tokio-stream = { version = "0.1.15", default-features = false, features = [
|
||||||
"sync",
|
"sync",
|
||||||
] }
|
] }
|
||||||
crossterm = "0.28.1"
|
crossterm = "0.29.0"
|
||||||
chrono = "0.4.23"
|
chrono = "0.4.23"
|
||||||
bincode = { version = "2.0.0", features = [
|
bincode = { version = "2.0.0", features = [
|
||||||
"serde",
|
"serde",
|
||||||
@@ -90,14 +91,16 @@ strum_macros = "0.27.2"
|
|||||||
indoc = "2.0.6"
|
indoc = "2.0.6"
|
||||||
rmcp = { version = "0.16.0", features = ["client", "transport-child-process"] }
|
rmcp = { version = "0.16.0", features = ["client", "transport-child-process"] }
|
||||||
num_cpus = "1.17.0"
|
num_cpus = "1.17.0"
|
||||||
rustpython-parser = "0.4.0"
|
tree-sitter = "0.26.8"
|
||||||
rustpython-ast = "0.4.0"
|
tree-sitter-language = "0.1"
|
||||||
|
tree-sitter-python = "0.25.0"
|
||||||
|
tree-sitter-typescript = "0.23"
|
||||||
colored = "3.0.0"
|
colored = "3.0.0"
|
||||||
clap_complete = { version = "4.5.58", features = ["unstable-dynamic"] }
|
clap_complete = { version = "4.5.58", features = ["unstable-dynamic"] }
|
||||||
gman = "0.3.0"
|
gman = "0.4.1"
|
||||||
clap_complete_nushell = "4.5.9"
|
clap_complete_nushell = "4.5.9"
|
||||||
open = "5"
|
open = "5"
|
||||||
rand = "0.9.0"
|
rand = { version = "0.10.0", features = ["default"] }
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
|
||||||
[dependencies.reqwest]
|
[dependencies.reqwest]
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
|
|||||||
* [Function Calling](./docs/function-calling/TOOLS.md#Tools): Leverage function calling capabilities to extend Loki's functionality with custom tools
|
* [Function Calling](./docs/function-calling/TOOLS.md#Tools): Leverage function calling capabilities to extend Loki's functionality with custom tools
|
||||||
* [Creating Custom Tools](./docs/function-calling/CUSTOM-TOOLS.md): You can create your own custom tools to enhance Loki's capabilities.
|
* [Creating Custom Tools](./docs/function-calling/CUSTOM-TOOLS.md): You can create your own custom tools to enhance Loki's capabilities.
|
||||||
* [Create Custom Python Tools](./docs/function-calling/CUSTOM-TOOLS.md#custom-python-based-tools)
|
* [Create Custom Python Tools](./docs/function-calling/CUSTOM-TOOLS.md#custom-python-based-tools)
|
||||||
|
* [Create Custom TypeScript Tools](./docs/function-calling/CUSTOM-TOOLS.md#custom-typescript-based-tools)
|
||||||
* [Create Custom Bash Tools](./docs/function-calling/CUSTOM-BASH-TOOLS.md)
|
* [Create Custom Bash Tools](./docs/function-calling/CUSTOM-BASH-TOOLS.md)
|
||||||
* [Bash Prompt Utilities](./docs/function-calling/BASH-PROMPT-HELPERS.md)
|
* [Bash Prompt Utilities](./docs/function-calling/BASH-PROMPT-HELPERS.md)
|
||||||
* [First-Class MCP Server Support](./docs/function-calling/MCP-SERVERS.md): Easily connect and interact with MCP servers for advanced functionality.
|
* [First-Class MCP Server Support](./docs/function-calling/MCP-SERVERS.md): Easily connect and interact with MCP servers for advanced functionality.
|
||||||
|
|||||||
@@ -2,68 +2,6 @@
|
|||||||
# Shared Agent Utilities - Minimal, focused helper functions
|
# Shared Agent Utilities - Minimal, focused helper functions
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
#############################
|
|
||||||
## CONTEXT FILE MANAGEMENT ##
|
|
||||||
#############################
|
|
||||||
|
|
||||||
get_context_file() {
|
|
||||||
local project_dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
|
|
||||||
echo "${project_dir}/.loki-context"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Initialize context file for a new task
|
|
||||||
# Usage: init_context "Task description"
|
|
||||||
init_context() {
|
|
||||||
local task="$1"
|
|
||||||
local project_dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
|
|
||||||
local context_file
|
|
||||||
context_file=$(get_context_file)
|
|
||||||
|
|
||||||
cat > "${context_file}" <<EOF
|
|
||||||
## Project: ${project_dir}
|
|
||||||
## Task: ${task}
|
|
||||||
## Started: $(date -Iseconds)
|
|
||||||
|
|
||||||
### Prior Findings
|
|
||||||
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
# Append findings to the context file
|
|
||||||
# Usage: append_context "agent_name" "finding summary
|
|
||||||
append_context() {
|
|
||||||
local agent="$1"
|
|
||||||
local finding="$2"
|
|
||||||
local context_file
|
|
||||||
context_file=$(get_context_file)
|
|
||||||
|
|
||||||
if [[ -f "${context_file}" ]]; then
|
|
||||||
{
|
|
||||||
echo ""
|
|
||||||
echo "[${agent}]:"
|
|
||||||
echo "${finding}"
|
|
||||||
} >> "${context_file}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read the current context (returns empty string if no context)
|
|
||||||
# Usage: context=$(read_context)
|
|
||||||
read_context() {
|
|
||||||
local context_file
|
|
||||||
context_file=$(get_context_file)
|
|
||||||
|
|
||||||
if [[ -f "${context_file}" ]]; then
|
|
||||||
cat "${context_file}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Clear the context file
|
|
||||||
clear_context() {
|
|
||||||
local context_file
|
|
||||||
context_file=$(get_context_file)
|
|
||||||
rm -f "${context_file}"
|
|
||||||
}
|
|
||||||
|
|
||||||
#######################
|
#######################
|
||||||
## PROJECT DETECTION ##
|
## PROJECT DETECTION ##
|
||||||
#######################
|
#######################
|
||||||
@@ -348,80 +286,11 @@ detect_project() {
|
|||||||
echo '{"type":"unknown","build":"","test":"","check":""}'
|
echo '{"type":"unknown","build":"","test":"","check":""}'
|
||||||
}
|
}
|
||||||
|
|
||||||
######################
|
|
||||||
## AGENT INVOCATION ##
|
|
||||||
######################
|
|
||||||
|
|
||||||
# Invoke a subagent with optional context injection
|
|
||||||
# Usage: invoke_agent <agent_name> <prompt> [extra_args...]
|
|
||||||
invoke_agent() {
|
|
||||||
local agent="$1"
|
|
||||||
local prompt="$2"
|
|
||||||
shift 2
|
|
||||||
|
|
||||||
local context
|
|
||||||
context=$(read_context)
|
|
||||||
|
|
||||||
local full_prompt
|
|
||||||
if [[ -n "${context}" ]]; then
|
|
||||||
full_prompt="## Orchestrator Context
|
|
||||||
|
|
||||||
The orchestrator (sisyphus) has gathered this context from prior work:
|
|
||||||
|
|
||||||
<context>
|
|
||||||
${context}
|
|
||||||
</context>
|
|
||||||
|
|
||||||
## Your Task
|
|
||||||
|
|
||||||
${prompt}"
|
|
||||||
else
|
|
||||||
full_prompt="${prompt}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
env AUTO_CONFIRM=true loki --agent "${agent}" "$@" "${full_prompt}" 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Invoke a subagent and capture a summary of its findings
|
|
||||||
# Usage: result=$(invoke_agent_with_summary "explore" "find auth patterns")
|
|
||||||
invoke_agent_with_summary() {
|
|
||||||
local agent="$1"
|
|
||||||
local prompt="$2"
|
|
||||||
shift 2
|
|
||||||
|
|
||||||
local output
|
|
||||||
output=$(invoke_agent "${agent}" "${prompt}" "$@")
|
|
||||||
|
|
||||||
local summary=""
|
|
||||||
|
|
||||||
if echo "${output}" | grep -q "FINDINGS:"; then
|
|
||||||
summary=$(echo "${output}" | sed -n '/FINDINGS:/,/^[A-Z_]*COMPLETE/p' | grep "^- " | sed 's/^- / - /')
|
|
||||||
elif echo "${output}" | grep -q "CODER_COMPLETE:"; then
|
|
||||||
summary=$(echo "${output}" | grep "CODER_COMPLETE:" | sed 's/CODER_COMPLETE: *//')
|
|
||||||
elif echo "${output}" | grep -q "ORACLE_COMPLETE"; then
|
|
||||||
summary=$(echo "${output}" | sed -n '/^## Recommendation/,/^## /{/^## Recommendation/d;/^## /d;p}' | sed '/^$/d' | head -10)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${summary}" ]]; then
|
|
||||||
summary=$(echo "${output}" | grep -v "^$" | grep -v "^#" | grep -v "^\-\-\-" | tail -10 | head -5)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${summary}" ]]; then
|
|
||||||
append_context "${agent}" "${summary}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${output}" ]] || [[ -z "$(echo "${output}" | tr -d '[:space:]')" ]]; then
|
|
||||||
echo "[${agent} agent completed but produced no text output. The agent may have performed work via tool calls (file writes, builds, etc.) that did not generate visible text. Check the project directory for changes.]"
|
|
||||||
else
|
|
||||||
echo "${output}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
###########################
|
###########################
|
||||||
## FILE SEARCH UTILITIES ##
|
## FILE SEARCH UTILITIES ##
|
||||||
###########################
|
###########################
|
||||||
|
|
||||||
search_files() {
|
_search_files() {
|
||||||
local pattern="$1"
|
local pattern="$1"
|
||||||
local dir="${2:-.}"
|
local dir="${2:-.}"
|
||||||
|
|
||||||
|
|||||||
@@ -122,3 +122,6 @@ instructions: |
|
|||||||
- Project: {{project_dir}}
|
- Project: {{project_dir}}
|
||||||
- CWD: {{__cwd__}}
|
- CWD: {{__cwd__}}
|
||||||
- Shell: {{__shell__}}
|
- Shell: {{__shell__}}
|
||||||
|
|
||||||
|
## Available Tools:
|
||||||
|
{{__tools__}}
|
||||||
|
|||||||
@@ -29,12 +29,31 @@ instructions: |
|
|||||||
## Your Mission
|
## Your Mission
|
||||||
|
|
||||||
Given an implementation task:
|
Given an implementation task:
|
||||||
1. Understand what to build (from context provided)
|
1. Check for orchestrator context first (see below)
|
||||||
2. Study existing patterns (read 1-2 similar files)
|
2. Fill gaps only. Read files NOT already covered in context
|
||||||
3. Write the code (using tools, NOT chat output)
|
3. Write the code (using tools, NOT chat output)
|
||||||
4. Verify it compiles/builds
|
4. Verify it compiles/builds
|
||||||
5. Signal completion with a summary
|
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
|
## Todo System
|
||||||
|
|
||||||
For multi-file changes:
|
For multi-file changes:
|
||||||
@@ -105,3 +124,6 @@ instructions: |
|
|||||||
- Project: {{project_dir}}
|
- Project: {{project_dir}}
|
||||||
- CWD: {{__cwd__}}
|
- CWD: {{__cwd__}}
|
||||||
- Shell: {{__shell__}}
|
- Shell: {{__shell__}}
|
||||||
|
|
||||||
|
## Available tools:
|
||||||
|
{{__tools__}}
|
||||||
@@ -69,6 +69,9 @@ instructions: |
|
|||||||
- Project: {{project_dir}}
|
- Project: {{project_dir}}
|
||||||
- CWD: {{__cwd__}}
|
- CWD: {{__cwd__}}
|
||||||
|
|
||||||
|
## Available Tools:
|
||||||
|
{{__tools__}}
|
||||||
|
|
||||||
conversation_starters:
|
conversation_starters:
|
||||||
- 'Find how authentication is implemented'
|
- 'Find how authentication is implemented'
|
||||||
- 'What patterns are used for API endpoints'
|
- 'What patterns are used for API endpoints'
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ search_files() {
|
|||||||
echo "" >> "$LLM_OUTPUT"
|
echo "" >> "$LLM_OUTPUT"
|
||||||
|
|
||||||
local results
|
local results
|
||||||
results=$(search_files "${pattern}" "${project_dir}")
|
results=$(_search_files "${pattern}" "${project_dir}")
|
||||||
|
|
||||||
if [[ -n "${results}" ]]; then
|
if [[ -n "${results}" ]]; then
|
||||||
echo "${results}" >> "$LLM_OUTPUT"
|
echo "${results}" >> "$LLM_OUTPUT"
|
||||||
|
|||||||
@@ -108,3 +108,6 @@ instructions: |
|
|||||||
## Context
|
## Context
|
||||||
- Project: {{project_dir}}
|
- Project: {{project_dir}}
|
||||||
- CWD: {{__cwd__}}
|
- CWD: {{__cwd__}}
|
||||||
|
|
||||||
|
## Available Tools:
|
||||||
|
{{__tools__}}
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ instructions: |
|
|||||||
- Project: {{project_dir}}
|
- Project: {{project_dir}}
|
||||||
- CWD: {{__cwd__}}
|
- CWD: {{__cwd__}}
|
||||||
|
|
||||||
|
## Available Tools:
|
||||||
|
{{__tools__}}
|
||||||
|
|
||||||
conversation_starters:
|
conversation_starters:
|
||||||
- 'Review this architecture design'
|
- 'Review this architecture design'
|
||||||
- 'Help debug this complex issue'
|
- 'Help debug this complex issue'
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ can_spawn_agents: true
|
|||||||
max_concurrent_agents: 4
|
max_concurrent_agents: 4
|
||||||
max_agent_depth: 3
|
max_agent_depth: 3
|
||||||
inject_spawn_instructions: true
|
inject_spawn_instructions: true
|
||||||
summarization_threshold: 4000
|
summarization_threshold: 8000
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
- name: project_dir
|
- name: project_dir
|
||||||
@@ -70,6 +70,45 @@ instructions: |
|
|||||||
| coder | Write/edit files, implement features | Creates/modifies files, runs builds |
|
| coder | Write/edit files, implement features | Creates/modifies files, runs builds |
|
||||||
| oracle | Architecture decisions, complex debugging | Advisory, high-quality reasoning |
|
| oracle | Architecture decisions, complex debugging | Advisory, high-quality reasoning |
|
||||||
|
|
||||||
|
## Coder Delegation Format (MANDATORY)
|
||||||
|
|
||||||
|
When spawning the `coder` agent, your prompt MUST include these sections.
|
||||||
|
The coder has NOT seen the codebase. Your prompt IS its entire context.
|
||||||
|
|
||||||
|
### Template:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Goal
|
||||||
|
[1-2 sentences: what to build/modify and where]
|
||||||
|
|
||||||
|
## Reference Files
|
||||||
|
[Files that explore found, with what each demonstrates]
|
||||||
|
- `path/to/file.ext` - what pattern this file shows
|
||||||
|
- `path/to/other.ext` - what convention this file shows
|
||||||
|
|
||||||
|
## Code Patterns to Follow
|
||||||
|
[Paste ACTUAL code snippets from explore results, not descriptions]
|
||||||
|
<code>
|
||||||
|
// From path/to/file.ext - this is the pattern to follow:
|
||||||
|
[actual code explore found, 5-20 lines]
|
||||||
|
</code>
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
[Naming, imports, error handling, file organization]
|
||||||
|
- Convention 1
|
||||||
|
- Convention 2
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
[What NOT to do, scope boundaries]
|
||||||
|
- Do NOT modify X
|
||||||
|
- Only touch files in Y/
|
||||||
|
```
|
||||||
|
|
||||||
|
**CRITICAL**: Include actual code snippets, not just file paths.
|
||||||
|
If explore returned code patterns, paste them into the coder prompt.
|
||||||
|
Vague prompts like "follow existing patterns" waste coder's tokens on
|
||||||
|
re-exploration that you already did.
|
||||||
|
|
||||||
## Workflow Examples
|
## Workflow Examples
|
||||||
|
|
||||||
### Example 1: Implementation task (explore -> coder, parallel exploration)
|
### Example 1: Implementation task (explore -> coder, parallel exploration)
|
||||||
@@ -81,12 +120,12 @@ instructions: |
|
|||||||
2. todo__add --task "Explore existing API patterns"
|
2. todo__add --task "Explore existing API patterns"
|
||||||
3. todo__add --task "Implement profile endpoint"
|
3. todo__add --task "Implement profile endpoint"
|
||||||
4. todo__add --task "Verify with build/test"
|
4. todo__add --task "Verify with build/test"
|
||||||
5. agent__spawn --agent explore --prompt "Find existing API endpoint patterns, route structures, and controller conventions"
|
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"
|
6. agent__spawn --agent explore --prompt "Find existing data models and database query patterns. Include code snippets."
|
||||||
7. agent__collect --id <id1>
|
7. agent__collect --id <id1>
|
||||||
8. agent__collect --id <id2>
|
8. agent__collect --id <id2>
|
||||||
9. todo__done --id 1
|
9. todo__done --id 1
|
||||||
10. agent__spawn --agent coder --prompt "Create user profiles endpoint following existing patterns. [Include context from explore results]"
|
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>
|
11. agent__collect --id <coder_id>
|
||||||
12. todo__done --id 2
|
12. todo__done --id 2
|
||||||
13. run_build
|
13. run_build
|
||||||
@@ -135,7 +174,6 @@ instructions: |
|
|||||||
|
|
||||||
## When to Do It Yourself
|
## When to Do It Yourself
|
||||||
|
|
||||||
- Single-file reads/writes
|
|
||||||
- Simple command execution
|
- Simple command execution
|
||||||
- Trivial changes (typos, renames)
|
- Trivial changes (typos, renames)
|
||||||
- Quick file searches
|
- Quick file searches
|
||||||
|
|||||||
@@ -50,7 +50,13 @@ def parse_raw_data(data):
|
|||||||
|
|
||||||
def parse_argv():
|
def parse_argv():
|
||||||
agent_func = sys.argv[1]
|
agent_func = sys.argv[1]
|
||||||
agent_data = sys.argv[2]
|
|
||||||
|
tool_data_file = os.environ.get("LLM_TOOL_DATA_FILE")
|
||||||
|
if tool_data_file and os.path.isfile(tool_data_file):
|
||||||
|
with open(tool_data_file, "r", encoding="utf-8") as f:
|
||||||
|
agent_data = f.read()
|
||||||
|
else:
|
||||||
|
agent_data = sys.argv[2]
|
||||||
|
|
||||||
if (not agent_data) or (not agent_func):
|
if (not agent_data) or (not agent_func):
|
||||||
print("Usage: ./{agent_name}.py <agent-func> <agent-data>", file=sys.stderr)
|
print("Usage: ./{agent_name}.py <agent-func> <agent-data>", file=sys.stderr)
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ main() {
|
|||||||
|
|
||||||
parse_argv() {
|
parse_argv() {
|
||||||
agent_func="$1"
|
agent_func="$1"
|
||||||
agent_data="$2"
|
if [[ -n "$LLM_TOOL_DATA_FILE" ]] && [[ -f "$LLM_TOOL_DATA_FILE" ]]; then
|
||||||
|
agent_data="$(cat "$LLM_TOOL_DATA_FILE")"
|
||||||
|
else
|
||||||
|
agent_data="$2"
|
||||||
|
fi
|
||||||
if [[ -z "$agent_data" ]] || [[ -z "$agent_func" ]]; then
|
if [[ -z "$agent_data" ]] || [[ -z "$agent_func" ]]; then
|
||||||
die "usage: ./{agent_name}.sh <agent-func> <agent-data>"
|
die "usage: ./{agent_name}.sh <agent-func> <agent-data>"
|
||||||
fi
|
fi
|
||||||
@@ -57,7 +61,6 @@ run() {
|
|||||||
if [[ "$OS" == "Windows_NT" ]]; then
|
if [[ "$OS" == "Windows_NT" ]]; then
|
||||||
set -o igncr
|
set -o igncr
|
||||||
tools_path="$(cygpath -w "$tools_path")"
|
tools_path="$(cygpath -w "$tools_path")"
|
||||||
tool_data="$(echo "$tool_data" | sed 's/\\/\\\\/g')"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
jq_script="$(cat <<-'EOF'
|
jq_script="$(cat <<-'EOF'
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
// Usage: ./{agent_name}.ts <agent-func> <agent-data>
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { pathToFileURL } from "url";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const { agentFunc, rawData } = parseArgv();
|
||||||
|
const agentData = parseRawData(rawData);
|
||||||
|
|
||||||
|
const configDir = "{config_dir}";
|
||||||
|
setupEnv(configDir, agentFunc);
|
||||||
|
|
||||||
|
const agentToolsPath = join(configDir, "agents", "{agent_name}", "tools.ts");
|
||||||
|
await run(agentToolsPath, agentFunc, agentData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRawData(data: string): Record<string, unknown> {
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("No JSON data");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid JSON data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgv(): { agentFunc: string; rawData: string } {
|
||||||
|
const agentFunc = process.argv[2];
|
||||||
|
|
||||||
|
const toolDataFile = process.env["LLM_TOOL_DATA_FILE"];
|
||||||
|
let agentData: string;
|
||||||
|
if (toolDataFile && existsSync(toolDataFile)) {
|
||||||
|
agentData = readFileSync(toolDataFile, "utf-8");
|
||||||
|
} else {
|
||||||
|
agentData = process.argv[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agentFunc || !agentData) {
|
||||||
|
process.stderr.write("Usage: ./{agent_name}.ts <agent-func> <agent-data>\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { agentFunc, rawData: agentData };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEnv(configDir: string, agentFunc: string): void {
|
||||||
|
loadEnv(join(configDir, ".env"));
|
||||||
|
process.env["LLM_ROOT_DIR"] = configDir;
|
||||||
|
process.env["LLM_AGENT_NAME"] = "{agent_name}";
|
||||||
|
process.env["LLM_AGENT_FUNC"] = agentFunc;
|
||||||
|
process.env["LLM_AGENT_ROOT_DIR"] = join(configDir, "agents", "{agent_name}");
|
||||||
|
process.env["LLM_AGENT_CACHE_DIR"] = join(configDir, "cache", "{agent_name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEnv(filePath: string): void {
|
||||||
|
let lines: string[];
|
||||||
|
try {
|
||||||
|
lines = readFileSync(filePath, "utf-8").split("\n");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (line.startsWith("#") || !line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eqIdx = line.indexOf("=");
|
||||||
|
if (eqIdx === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = line.slice(0, eqIdx).trim();
|
||||||
|
if (key in process.env) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = line.slice(eqIdx + 1).trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractParamNames(fn: Function): string[] {
|
||||||
|
const src = fn.toString();
|
||||||
|
const match = src.match(/^(?:async\s+)?function\s*\w*\s*\(([^)]*)\)/);
|
||||||
|
if (!match) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return match[1]
|
||||||
|
.split(",")
|
||||||
|
.map((p) => p.trim().replace(/[:=?].*/s, "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spreadArgs(
|
||||||
|
fn: Function,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): unknown[] {
|
||||||
|
const names = extractParamNames(fn);
|
||||||
|
if (names.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return names.map((name) => data[name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(
|
||||||
|
agentPath: string,
|
||||||
|
agentFunc: string,
|
||||||
|
agentData: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const mod = await import(pathToFileURL(agentPath).href);
|
||||||
|
|
||||||
|
if (typeof mod[agentFunc] !== "function") {
|
||||||
|
throw new Error(`No module function '${agentFunc}' at '${agentPath}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn = mod[agentFunc] as Function;
|
||||||
|
const args = spreadArgs(fn, agentData);
|
||||||
|
const value = await fn(...args);
|
||||||
|
returnToLlm(value);
|
||||||
|
dumpResult(`{agent_name}:${agentFunc}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnToLlm(value: unknown): void {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = process.env["LLM_OUTPUT"];
|
||||||
|
const write = (s: string) => {
|
||||||
|
if (output) {
|
||||||
|
writeFileSync(output, s, "utf-8");
|
||||||
|
} else {
|
||||||
|
process.stdout.write(s);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||||
|
write(String(value));
|
||||||
|
} else if (typeof value === "object") {
|
||||||
|
write(JSON.stringify(value, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dumpResult(name: string): void {
|
||||||
|
const dumpResults = process.env["LLM_DUMP_RESULTS"];
|
||||||
|
const llmOutput = process.env["LLM_OUTPUT"];
|
||||||
|
|
||||||
|
if (!dumpResults || !llmOutput || !process.stdout.isTTY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pattern = new RegExp(`\\b(${dumpResults})\\b`);
|
||||||
|
if (!pattern.test(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: string;
|
||||||
|
try {
|
||||||
|
data = readFileSync(llmOutput, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`\x1b[2m----------------------\n${data}\n----------------------\x1b[0m\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(`${err}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -49,6 +49,11 @@ def parse_raw_data(data):
|
|||||||
|
|
||||||
|
|
||||||
def parse_argv():
|
def parse_argv():
|
||||||
|
tool_data_file = os.environ.get("LLM_TOOL_DATA_FILE")
|
||||||
|
if tool_data_file and os.path.isfile(tool_data_file):
|
||||||
|
with open(tool_data_file, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
argv = sys.argv[:] + [None] * max(0, 2 - len(sys.argv))
|
argv = sys.argv[:] + [None] * max(0, 2 - len(sys.argv))
|
||||||
|
|
||||||
tool_data = argv[1]
|
tool_data = argv[1]
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parse_argv() {
|
parse_argv() {
|
||||||
tool_data="$1"
|
if [[ -n "$LLM_TOOL_DATA_FILE" ]] && [[ -f "$LLM_TOOL_DATA_FILE" ]]; then
|
||||||
|
tool_data="$(cat "$LLM_TOOL_DATA_FILE")"
|
||||||
|
else
|
||||||
|
tool_data="$1"
|
||||||
|
fi
|
||||||
if [[ -z "$tool_data" ]]; then
|
if [[ -z "$tool_data" ]]; then
|
||||||
die "usage: ./{function_name}.sh <tool-data>"
|
die "usage: ./{function_name}.sh <tool-data>"
|
||||||
fi
|
fi
|
||||||
@@ -54,7 +58,6 @@ run() {
|
|||||||
if [[ "$OS" == "Windows_NT" ]]; then
|
if [[ "$OS" == "Windows_NT" ]]; then
|
||||||
set -o igncr
|
set -o igncr
|
||||||
tool_path="$(cygpath -w "$tool_path")"
|
tool_path="$(cygpath -w "$tool_path")"
|
||||||
tool_data="$(echo "$tool_data" | sed 's/\\/\\\\/g')"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
jq_script="$(cat <<-'EOF'
|
jq_script="$(cat <<-'EOF'
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
// Usage: ./{function_name}.ts <tool-data>
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { pathToFileURL } from "url";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const rawData = parseArgv();
|
||||||
|
const toolData = parseRawData(rawData);
|
||||||
|
|
||||||
|
const rootDir = "{root_dir}";
|
||||||
|
setupEnv(rootDir);
|
||||||
|
|
||||||
|
const toolPath = "{tool_path}.ts";
|
||||||
|
await run(toolPath, "run", toolData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRawData(data: string): Record<string, unknown> {
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("No JSON data");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid JSON data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgv(): string {
|
||||||
|
const toolDataFile = process.env["LLM_TOOL_DATA_FILE"];
|
||||||
|
if (toolDataFile && existsSync(toolDataFile)) {
|
||||||
|
return readFileSync(toolDataFile, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolData = process.argv[2];
|
||||||
|
|
||||||
|
if (!toolData) {
|
||||||
|
process.stderr.write("Usage: ./{function_name}.ts <tool-data>\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEnv(rootDir: string): void {
|
||||||
|
loadEnv(join(rootDir, ".env"));
|
||||||
|
process.env["LLM_ROOT_DIR"] = rootDir;
|
||||||
|
process.env["LLM_TOOL_NAME"] = "{function_name}";
|
||||||
|
process.env["LLM_TOOL_CACHE_DIR"] = join(rootDir, "cache", "{function_name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEnv(filePath: string): void {
|
||||||
|
let lines: string[];
|
||||||
|
try {
|
||||||
|
lines = readFileSync(filePath, "utf-8").split("\n");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (line.startsWith("#") || !line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eqIdx = line.indexOf("=");
|
||||||
|
if (eqIdx === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = line.slice(0, eqIdx).trim();
|
||||||
|
if (key in process.env) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = line.slice(eqIdx + 1).trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractParamNames(fn: Function): string[] {
|
||||||
|
const src = fn.toString();
|
||||||
|
const match = src.match(/^(?:async\s+)?function\s*\w*\s*\(([^)]*)\)/);
|
||||||
|
if (!match) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return match[1]
|
||||||
|
.split(",")
|
||||||
|
.map((p) => p.trim().replace(/[:=?].*/s, "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spreadArgs(
|
||||||
|
fn: Function,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): unknown[] {
|
||||||
|
const names = extractParamNames(fn);
|
||||||
|
if (names.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return names.map((name) => data[name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(
|
||||||
|
toolPath: string,
|
||||||
|
toolFunc: string,
|
||||||
|
toolData: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const mod = await import(pathToFileURL(toolPath).href);
|
||||||
|
|
||||||
|
if (typeof mod[toolFunc] !== "function") {
|
||||||
|
throw new Error(`No module function '${toolFunc}' at '${toolPath}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn = mod[toolFunc] as Function;
|
||||||
|
const args = spreadArgs(fn, toolData);
|
||||||
|
const value = await fn(...args);
|
||||||
|
returnToLlm(value);
|
||||||
|
dumpResult("{function_name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnToLlm(value: unknown): void {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = process.env["LLM_OUTPUT"];
|
||||||
|
const write = (s: string) => {
|
||||||
|
if (output) {
|
||||||
|
writeFileSync(output, s, "utf-8");
|
||||||
|
} else {
|
||||||
|
process.stdout.write(s);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||||
|
write(String(value));
|
||||||
|
} else if (typeof value === "object") {
|
||||||
|
write(JSON.stringify(value, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dumpResult(name: string): void {
|
||||||
|
const dumpResults = process.env["LLM_DUMP_RESULTS"];
|
||||||
|
const llmOutput = process.env["LLM_OUTPUT"];
|
||||||
|
|
||||||
|
if (!dumpResults || !llmOutput || !process.stdout.isTTY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pattern = new RegExp(`\\b(${dumpResults})\\b`);
|
||||||
|
if (!pattern.test(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: string;
|
||||||
|
try {
|
||||||
|
data = readFileSync(llmOutput, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`\x1b[2m----------------------\n${data}\n----------------------\x1b[0m\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(`${err}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from typing import List, Literal, Optional
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
|
||||||
def run(
|
def run(
|
||||||
string: str,
|
string: str,
|
||||||
string_enum: Literal["foo", "bar"],
|
string_enum: Literal["foo", "bar"],
|
||||||
@@ -9,26 +10,38 @@ def run(
|
|||||||
number: float,
|
number: float,
|
||||||
array: List[str],
|
array: List[str],
|
||||||
string_optional: Optional[str] = None,
|
string_optional: Optional[str] = None,
|
||||||
|
integer_with_default: int = 42,
|
||||||
|
boolean_with_default: bool = True,
|
||||||
|
number_with_default: float = 3.14,
|
||||||
|
string_with_default: str = "hello",
|
||||||
array_optional: Optional[List[str]] = None,
|
array_optional: Optional[List[str]] = None,
|
||||||
):
|
):
|
||||||
"""Demonstrates how to create a tool using Python and how to use comments.
|
"""Demonstrates all supported Python parameter types and variations.
|
||||||
Args:
|
Args:
|
||||||
string: Define a required string property
|
string: A required string property
|
||||||
string_enum: Define a required string property with enum
|
string_enum: A required string property constrained to specific values
|
||||||
boolean: Define a required boolean property
|
boolean: A required boolean property
|
||||||
integer: Define a required integer property
|
integer: A required integer property
|
||||||
number: Define a required number property
|
number: A required number (float) property
|
||||||
array: Define a required string array property
|
array: A required string array property
|
||||||
string_optional: Define an optional string property
|
string_optional: An optional string property (Optional[str] with None default)
|
||||||
array_optional: Define an optional string array property
|
integer_with_default: An optional integer with a non-None default value
|
||||||
|
boolean_with_default: An optional boolean with a default value
|
||||||
|
number_with_default: An optional number with a default value
|
||||||
|
string_with_default: An optional string with a default value
|
||||||
|
array_optional: An optional string array property
|
||||||
"""
|
"""
|
||||||
output = f"""string: {string}
|
output = f"""string: {string}
|
||||||
string_enum: {string_enum}
|
string_enum: {string_enum}
|
||||||
string_optional: {string_optional}
|
|
||||||
boolean: {boolean}
|
boolean: {boolean}
|
||||||
integer: {integer}
|
integer: {integer}
|
||||||
number: {number}
|
number: {number}
|
||||||
array: {array}
|
array: {array}
|
||||||
|
string_optional: {string_optional}
|
||||||
|
integer_with_default: {integer_with_default}
|
||||||
|
boolean_with_default: {boolean_with_default}
|
||||||
|
number_with_default: {number_with_default}
|
||||||
|
string_with_default: {string_with_default}
|
||||||
array_optional: {array_optional}"""
|
array_optional: {array_optional}"""
|
||||||
|
|
||||||
for key, value in os.environ.items():
|
for key, value in os.environ.items():
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Demonstrates all supported TypeScript parameter types and variations.
|
||||||
|
*
|
||||||
|
* @param string - A required string property
|
||||||
|
* @param string_enum - A required string property constrained to specific values
|
||||||
|
* @param boolean - A required boolean property
|
||||||
|
* @param number - A required number property
|
||||||
|
* @param array_bracket - A required string array using bracket syntax
|
||||||
|
* @param array_generic - A required string array using generic syntax
|
||||||
|
* @param string_optional - An optional string using the question mark syntax
|
||||||
|
* @param string_nullable - An optional string using the union-with-null syntax
|
||||||
|
* @param number_with_default - An optional number with a default value
|
||||||
|
* @param boolean_with_default - An optional boolean with a default value
|
||||||
|
* @param string_with_default - An optional string with a default value
|
||||||
|
* @param array_optional - An optional string array using the question mark syntax
|
||||||
|
*/
|
||||||
|
export function run(
|
||||||
|
string: string,
|
||||||
|
string_enum: "foo" | "bar",
|
||||||
|
boolean: boolean,
|
||||||
|
number: number,
|
||||||
|
array_bracket: string[],
|
||||||
|
array_generic: Array<string>,
|
||||||
|
string_optional?: string,
|
||||||
|
string_nullable: string | null = null,
|
||||||
|
number_with_default: number = 42,
|
||||||
|
boolean_with_default: boolean = true,
|
||||||
|
string_with_default: string = "hello",
|
||||||
|
array_optional?: string[],
|
||||||
|
): string {
|
||||||
|
const parts = [
|
||||||
|
`string: ${string}`,
|
||||||
|
`string_enum: ${string_enum}`,
|
||||||
|
`boolean: ${boolean}`,
|
||||||
|
`number: ${number}`,
|
||||||
|
`array_bracket: ${JSON.stringify(array_bracket)}`,
|
||||||
|
`array_generic: ${JSON.stringify(array_generic)}`,
|
||||||
|
`string_optional: ${string_optional}`,
|
||||||
|
`string_nullable: ${string_nullable}`,
|
||||||
|
`number_with_default: ${number_with_default}`,
|
||||||
|
`boolean_with_default: ${boolean_with_default}`,
|
||||||
|
`string_with_default: ${string_with_default}`,
|
||||||
|
`array_optional: ${JSON.stringify(array_optional)}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
if (key.startsWith("LLM_")) {
|
||||||
|
parts.push(`${key}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
import { appendFileSync, mkdirSync } from "fs";
|
||||||
|
import { dirname } from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current weather in a given location
|
||||||
|
* @param location - The city and optionally the state or country (e.g., "London", "San Francisco, CA").
|
||||||
|
*/
|
||||||
|
export async function run(location: string): string {
|
||||||
|
const encoded = encodeURIComponent(location);
|
||||||
|
const url = `https://wttr.in/${encoded}?format=4`;
|
||||||
|
|
||||||
|
const resp = await fetch(url);
|
||||||
|
const data = await resp.text();
|
||||||
|
|
||||||
|
const dest = process.env["LLM_OUTPUT"] ?? "/dev/stdout";
|
||||||
|
if (dest !== "-" && dest !== "/dev/stdout") {
|
||||||
|
mkdirSync(dirname(dest), { recursive: true });
|
||||||
|
appendFileSync(dest, data, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ enabled_tools: null # Which tools to enable by default. (e.g. 'fs,w
|
|||||||
visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools')
|
visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools')
|
||||||
# - demo_py.py
|
# - demo_py.py
|
||||||
# - demo_sh.sh
|
# - demo_sh.sh
|
||||||
|
# - demo_ts.ts
|
||||||
- execute_command.sh
|
- execute_command.sh
|
||||||
# - execute_py_code.py
|
# - execute_py_code.py
|
||||||
# - execute_sql_code.sh
|
# - execute_sql_code.sh
|
||||||
@@ -61,6 +62,7 @@ visible_tools: # Which tools are visible to be compiled (and a
|
|||||||
# - fs_write.sh
|
# - fs_write.sh
|
||||||
- get_current_time.sh
|
- get_current_time.sh
|
||||||
# - get_current_weather.py
|
# - get_current_weather.py
|
||||||
|
# - get_current_weather.ts
|
||||||
- get_current_weather.sh
|
- get_current_weather.sh
|
||||||
- query_jira_issues.sh
|
- query_jira_issues.sh
|
||||||
# - search_arxiv.sh
|
# - search_arxiv.sh
|
||||||
|
|||||||
+64
-10
@@ -33,6 +33,7 @@ If you're looking for more example agents, refer to the [built-in agents](../ass
|
|||||||
- [.env File Support](#env-file-support)
|
- [.env File Support](#env-file-support)
|
||||||
- [Python-Based Agent Tools](#python-based-agent-tools)
|
- [Python-Based Agent Tools](#python-based-agent-tools)
|
||||||
- [Bash-Based Agent Tools](#bash-based-agent-tools)
|
- [Bash-Based Agent Tools](#bash-based-agent-tools)
|
||||||
|
- [TypeScript-Based Agent Tools](#typescript-based-agent-tools)
|
||||||
- [5. Conversation Starters](#5-conversation-starters)
|
- [5. Conversation Starters](#5-conversation-starters)
|
||||||
- [6. Todo System & Auto-Continuation](#6-todo-system--auto-continuation)
|
- [6. Todo System & Auto-Continuation](#6-todo-system--auto-continuation)
|
||||||
- [7. Sub-Agent Spawning System](#7-sub-agent-spawning-system)
|
- [7. Sub-Agent Spawning System](#7-sub-agent-spawning-system)
|
||||||
@@ -62,10 +63,12 @@ Agent configurations often have the following directory structure:
|
|||||||
├── tools.sh
|
├── tools.sh
|
||||||
or
|
or
|
||||||
├── tools.py
|
├── tools.py
|
||||||
|
or
|
||||||
|
├── tools.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
This means that agent configurations often are only two files: the agent configuration file (`config.yaml`), and the
|
This means that agent configurations often are only two files: the agent configuration file (`config.yaml`), and the
|
||||||
tool definitions (`agents/my-agent/tools.sh` or `tools.py`).
|
tool definitions (`agents/my-agent/tools.sh`, `tools.py`, or `tools.ts`).
|
||||||
|
|
||||||
To see a full example configuration file, refer to the [example agent config file](../config.agent.example.yaml).
|
To see a full example configuration file, refer to the [example agent config file](../config.agent.example.yaml).
|
||||||
|
|
||||||
@@ -114,10 +117,10 @@ isolated environment, so in order for an agent to use a tool or MCP server that
|
|||||||
explicitly state which tools and/or MCP servers the agent uses. Otherwise, it is assumed that the agent doesn't use any
|
explicitly state which tools and/or MCP servers the agent uses. Otherwise, it is assumed that the agent doesn't use any
|
||||||
tools outside its own custom defined tools.
|
tools outside its own custom defined tools.
|
||||||
|
|
||||||
And if you don't define a `agents/my-agent/tools.sh` or `agents/my-agent/tools.py`, then the agent is really just a
|
And if you don't define a `agents/my-agent/tools.sh`, `agents/my-agent/tools.py`, or `agents/my-agent/tools.ts`, then the agent is really just a
|
||||||
`role`.
|
`role`.
|
||||||
|
|
||||||
You'll notice there's no settings for agent-specific tooling. This is because they are handled separately and
|
You'll notice there are no settings for agent-specific tooling. This is because they are handled separately and
|
||||||
automatically. See the [Building Tools for Agents](#4-building-tools-for-agents) section below for more information.
|
automatically. See the [Building Tools for Agents](#4-building-tools-for-agents) section below for more information.
|
||||||
|
|
||||||
To see a full example configuration file, refer to the [example agent config file](../config.agent.example.yaml).
|
To see a full example configuration file, refer to the [example agent config file](../config.agent.example.yaml).
|
||||||
@@ -205,7 +208,7 @@ variables:
|
|||||||
### Dynamic Instructions
|
### Dynamic Instructions
|
||||||
Sometimes you may find it useful to dynamically generate instructions on startup. Whether that be via a call to Loki
|
Sometimes you may find it useful to dynamically generate instructions on startup. Whether that be via a call to Loki
|
||||||
itself to generate them, or by some other means. Loki supports this type of behavior using a special function defined
|
itself to generate them, or by some other means. Loki supports this type of behavior using a special function defined
|
||||||
in your `agents/my-agent/tools.py` or `agents/my-agent/tools.sh`.
|
in your `agents/my-agent/tools.py`, `agents/my-agent/tools.sh`, or `agents/my-agent/tools.ts`.
|
||||||
|
|
||||||
**Example: Instructions for a JSON-reader agent that specializes on each JSON input it receives**
|
**Example: Instructions for a JSON-reader agent that specializes on each JSON input it receives**
|
||||||
`agents/json-reader/tools.py`:
|
`agents/json-reader/tools.py`:
|
||||||
@@ -306,8 +309,8 @@ EOF
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For more information on how to create custom tools for your agent and the structure of the `agent/my-agent/tools.sh` or
|
For more information on how to create custom tools for your agent and the structure of the `agent/my-agent/tools.sh`,
|
||||||
`agent/my-agent/tools.py` files, refer to the [Building Tools for Agents](#4-building-tools-for-agents) section below.
|
`agent/my-agent/tools.py`, or `agent/my-agent/tools.ts` files, refer to the [Building Tools for Agents](#4-building-tools-for-agents) section below.
|
||||||
|
|
||||||
#### Variables
|
#### Variables
|
||||||
All the same variable interpolations supported by static instructions is also supported by dynamic instructions. For
|
All the same variable interpolations supported by static instructions is also supported by dynamic instructions. For
|
||||||
@@ -337,10 +340,11 @@ defining a single function that gets executed at runtime (e.g. `main` for bash t
|
|||||||
tools define a number of *subcommands*.
|
tools define a number of *subcommands*.
|
||||||
|
|
||||||
### Limitations
|
### Limitations
|
||||||
You can only utilize either a bash-based `<loki-config-dir>/agents/my-agent/tools.sh` or a Python-based
|
You can only utilize one of: a bash-based `<loki-config-dir>/agents/my-agent/tools.sh`, a Python-based
|
||||||
`<loki-config-dir>/agents/my-agent/tools.py`. However, if it's easier to achieve a task in one language vs the other,
|
`<loki-config-dir>/agents/my-agent/tools.py`, or a TypeScript-based `<loki-config-dir>/agents/my-agent/tools.ts`.
|
||||||
|
However, if it's easier to achieve a task in one language vs the other,
|
||||||
you're free to define other scripts in your agent's configuration directory and reference them from the main
|
you're free to define other scripts in your agent's configuration directory and reference them from the main
|
||||||
`tools.py/sh` file. **Any scripts *not* named `tools.{py,sh}` will not be picked up by Loki's compiler**, meaning they
|
tools file. **Any scripts *not* named `tools.{py,sh,ts}` will not be picked up by Loki's compiler**, meaning they
|
||||||
can be used like any other set of scripts.
|
can be used like any other set of scripts.
|
||||||
|
|
||||||
It's important to keep in mind the following:
|
It's important to keep in mind the following:
|
||||||
@@ -428,6 +432,55 @@ the same syntax ad formatting as is used to create custom bash tools globally.
|
|||||||
For more information on how to write, [build and test](function-calling/CUSTOM-BASH-TOOLS.md#execute-and-test-your-bash-tools) tools in bash, refer to the
|
For more information on how to write, [build and test](function-calling/CUSTOM-BASH-TOOLS.md#execute-and-test-your-bash-tools) tools in bash, refer to the
|
||||||
[custom bash tools documentation](function-calling/CUSTOM-BASH-TOOLS.md).
|
[custom bash tools documentation](function-calling/CUSTOM-BASH-TOOLS.md).
|
||||||
|
|
||||||
|
### TypeScript-Based Agent Tools
|
||||||
|
TypeScript-based agent tools work exactly the same as TypeScript global tools. Instead of a single `run` function,
|
||||||
|
you define as many exported functions as you like. Non-exported functions are private helpers and are invisible to the
|
||||||
|
LLM.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
`agents/my-agent/tools.ts`
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Get your IP information
|
||||||
|
*/
|
||||||
|
export async function get_ip_info(): Promise<string> {
|
||||||
|
const resp = await fetch("https://httpbin.org/ip");
|
||||||
|
return await resp.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find your public IP address using AWS
|
||||||
|
*/
|
||||||
|
export async function get_ip_address_from_aws(): Promise<string> {
|
||||||
|
const resp = await fetch("https://checkip.amazonaws.com");
|
||||||
|
return await resp.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-exported helper — invisible to the LLM
|
||||||
|
function formatResponse(data: string): string {
|
||||||
|
return data.trim();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Loki automatically compiles each exported function as a separate tool for the LLM to call. Just make sure you
|
||||||
|
follow the same JSDoc and parameter conventions as you would when creating custom TypeScript tools.
|
||||||
|
|
||||||
|
TypeScript agent tools also support dynamic instructions via an exported `_instructions()` function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates instructions for the agent dynamically
|
||||||
|
*/
|
||||||
|
export function _instructions(): string {
|
||||||
|
const schema = readFileSync("schema.json", "utf-8");
|
||||||
|
return `You are an AI agent that works with the following schema:\n${schema}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information on how to build tools in TypeScript, refer to the [custom TypeScript tools documentation](function-calling/CUSTOM-TOOLS.md#custom-typescript-based-tools).
|
||||||
|
|
||||||
## 5. Conversation Starters
|
## 5. Conversation Starters
|
||||||
It's often helpful to also have some conversation starters so users know what kinds of things the agent is capable of
|
It's often helpful to also have some conversation starters so users know what kinds of things the agent is capable of
|
||||||
doing. These are available in the REPL via the `.starter` command and are selectable.
|
doing. These are available in the REPL via the `.starter` command and are selectable.
|
||||||
@@ -467,11 +520,12 @@ inject_todo_instructions: true # Include the default todo instructions into pr
|
|||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
1. When `inject_todo_instructions` is enabled, agents receive instructions on using four built-in tools:
|
1. When `inject_todo_instructions` is enabled, agents receive instructions on using five built-in tools:
|
||||||
- `todo__init`: Initialize a todo list with a goal
|
- `todo__init`: Initialize a todo list with a goal
|
||||||
- `todo__add`: Add a task to the list
|
- `todo__add`: Add a task to the list
|
||||||
- `todo__done`: Mark a task complete
|
- `todo__done`: Mark a task complete
|
||||||
- `todo__list`: View current todo state
|
- `todo__list`: View current todo state
|
||||||
|
- `todo__clear`: Clear the entire todo list and reset the goal
|
||||||
|
|
||||||
These instructions are a reasonable default that detail how to use Loki's To-Do System. If you wish,
|
These instructions are a reasonable default that detail how to use Loki's To-Do System. If you wish,
|
||||||
you can disable the injection of the default instructions and specify your own instructions for how
|
you can disable the injection of the default instructions and specify your own instructions for how
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ The following variables can be used to change the log level of Loki or the locat
|
|||||||
can also pass the `--disable-log-colors` flag as well.
|
can also pass the `--disable-log-colors` flag as well.
|
||||||
|
|
||||||
## Miscellaneous Variables
|
## Miscellaneous Variables
|
||||||
| Environment Variable | Description | Default Value |
|
| Environment Variable | Description | Default Value |
|
||||||
|----------------------|--------------------------------------------------------------------------------------------------|---------------|
|
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|
|
||||||
| `AUTO_CONFIRM` | Bypass all `guard_*` checks in the bash prompt helpers; useful for agent composition and routing | |
|
| `AUTO_CONFIRM` | Bypass all `guard_*` checks in the bash prompt helpers; useful for agent composition and routing | |
|
||||||
|
| `LLM_TOOL_DATA_FILE` | Set automatically by Loki on Windows. Points to a temporary file containing the JSON tool call data. <br>Tool scripts (`run-tool.sh`, `run-agent.sh`, etc.) read from this file instead of command-line args <br>to avoid JSON escaping issues when data passes through `cmd.exe` → bash. **Not intended to be set by users.** | |
|
||||||
+8
-7
@@ -120,13 +120,14 @@ For more information on sessions and how to use them in Loki, refer to the [sess
|
|||||||
Loki lets you build OpenAI GPT-style agents. The following commands let you interact with and manage your agents in
|
Loki lets you build OpenAI GPT-style agents. The following commands let you interact with and manage your agents in
|
||||||
Loki:
|
Loki:
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|----------------------|------------------------------------------------------------|
|
|----------------------|-----------------------------------------------------------------------------------------------|
|
||||||
| `.agent` | Use an agent |
|
| `.agent` | Use an agent |
|
||||||
| `.starter` | Display and use conversation starters for the active agent |
|
| `.starter` | Display and use conversation starters for the active agent |
|
||||||
| `.edit agent-config` | Open the agent configuration in your preferred text editor |
|
| `.clear todo` | Clear the todo list and stop auto-continuation (requires `auto_continue: true` on the agent) |
|
||||||
| `.info agent` | Display information about the active agent |
|
| `.edit agent-config` | Open the agent configuration in your preferred text editor |
|
||||||
| `.exit agent` | Leave the active agent |
|
| `.info agent` | Display information about the active agent |
|
||||||
|
| `.exit agent` | Leave the active agent |
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,22 @@ Display the current todo list with status of each item.
|
|||||||
|
|
||||||
**Returns:** The full todo list with goal, progress, and item statuses
|
**Returns:** The full todo list with goal, progress, and item statuses
|
||||||
|
|
||||||
|
### `todo__clear`
|
||||||
|
Clear the entire todo list and reset the goal. Use when the current task has been canceled or invalidated.
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Returns:** Confirmation that the todo list was cleared
|
||||||
|
|
||||||
|
### REPL Command: `.clear todo`
|
||||||
|
You can also clear the todo list manually from the REPL by typing `.clear todo`. This is useful when:
|
||||||
|
- You gave a custom response that changes or cancels the current task
|
||||||
|
- The agent is stuck in auto-continuation with stale todos
|
||||||
|
- You want to start fresh without leaving and re-entering the agent
|
||||||
|
|
||||||
|
**Note:** This command is only available when an agent with `auto_continue: true` is active. If the todo
|
||||||
|
system isn't enabled for the current agent, the command will display an error message.
|
||||||
|
|
||||||
## Auto-Continuation
|
## Auto-Continuation
|
||||||
When `auto_continue` is enabled, Loki automatically sends a continuation prompt if:
|
When `auto_continue` is enabled, Loki automatically sends a continuation prompt if:
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ into your Loki setup. This document provides a guide on how to create and use cu
|
|||||||
- [Environment Variables](#environment-variables)
|
- [Environment Variables](#environment-variables)
|
||||||
- [Custom Bash-Based Tools](#custom-bash-based-tools)
|
- [Custom Bash-Based Tools](#custom-bash-based-tools)
|
||||||
- [Custom Python-Based Tools](#custom-python-based-tools)
|
- [Custom Python-Based Tools](#custom-python-based-tools)
|
||||||
|
- [Custom TypeScript-Based Tools](#custom-typescript-based-tools)
|
||||||
|
- [Custom Runtime](#custom-runtime)
|
||||||
<!--toc:end-->
|
<!--toc:end-->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -19,9 +21,10 @@ Loki supports custom tools written in the following programming languages:
|
|||||||
|
|
||||||
* Python
|
* Python
|
||||||
* Bash
|
* Bash
|
||||||
|
* TypeScript
|
||||||
|
|
||||||
## Creating a Custom Tool
|
## Creating a Custom Tool
|
||||||
All tools are created as scripts in either Python or Bash. They should be placed in the `functions/tools` directory.
|
All tools are created as scripts in either Python, Bash, or TypeScript. They should be placed in the `functions/tools` directory.
|
||||||
The location of the `functions` directory varies between systems, so you can use the following command to locate
|
The location of the `functions` directory varies between systems, so you can use the following command to locate
|
||||||
your `functions` directory:
|
your `functions` directory:
|
||||||
|
|
||||||
@@ -81,6 +84,7 @@ Loki and demonstrates how to create a Python-based tool:
|
|||||||
import os
|
import os
|
||||||
from typing import List, Literal, Optional
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
|
||||||
def run(
|
def run(
|
||||||
string: str,
|
string: str,
|
||||||
string_enum: Literal["foo", "bar"],
|
string_enum: Literal["foo", "bar"],
|
||||||
@@ -89,26 +93,38 @@ def run(
|
|||||||
number: float,
|
number: float,
|
||||||
array: List[str],
|
array: List[str],
|
||||||
string_optional: Optional[str] = None,
|
string_optional: Optional[str] = None,
|
||||||
|
integer_with_default: int = 42,
|
||||||
|
boolean_with_default: bool = True,
|
||||||
|
number_with_default: float = 3.14,
|
||||||
|
string_with_default: str = "hello",
|
||||||
array_optional: Optional[List[str]] = None,
|
array_optional: Optional[List[str]] = None,
|
||||||
):
|
):
|
||||||
"""Demonstrates how to create a tool using Python and how to use comments.
|
"""Demonstrates all supported Python parameter types and variations.
|
||||||
Args:
|
Args:
|
||||||
string: Define a required string property
|
string: A required string property
|
||||||
string_enum: Define a required string property with enum
|
string_enum: A required string property constrained to specific values
|
||||||
boolean: Define a required boolean property
|
boolean: A required boolean property
|
||||||
integer: Define a required integer property
|
integer: A required integer property
|
||||||
number: Define a required number property
|
number: A required number (float) property
|
||||||
array: Define a required string array property
|
array: A required string array property
|
||||||
string_optional: Define an optional string property
|
string_optional: An optional string property (Optional[str] with None default)
|
||||||
array_optional: Define an optional string array property
|
integer_with_default: An optional integer with a non-None default value
|
||||||
|
boolean_with_default: An optional boolean with a default value
|
||||||
|
number_with_default: An optional number with a default value
|
||||||
|
string_with_default: An optional string with a default value
|
||||||
|
array_optional: An optional string array property
|
||||||
"""
|
"""
|
||||||
output = f"""string: {string}
|
output = f"""string: {string}
|
||||||
string_enum: {string_enum}
|
string_enum: {string_enum}
|
||||||
string_optional: {string_optional}
|
|
||||||
boolean: {boolean}
|
boolean: {boolean}
|
||||||
integer: {integer}
|
integer: {integer}
|
||||||
number: {number}
|
number: {number}
|
||||||
array: {array}
|
array: {array}
|
||||||
|
string_optional: {string_optional}
|
||||||
|
integer_with_default: {integer_with_default}
|
||||||
|
boolean_with_default: {boolean_with_default}
|
||||||
|
number_with_default: {number_with_default}
|
||||||
|
string_with_default: {string_with_default}
|
||||||
array_optional: {array_optional}"""
|
array_optional: {array_optional}"""
|
||||||
|
|
||||||
for key, value in os.environ.items():
|
for key, value in os.environ.items():
|
||||||
@@ -117,3 +133,150 @@ array_optional: {array_optional}"""
|
|||||||
|
|
||||||
return output
|
return output
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Custom TypeScript-Based Tools
|
||||||
|
Loki supports tools written in TypeScript. TypeScript tools require [Node.js](https://nodejs.org/) and
|
||||||
|
[tsx](https://tsx.is/) (`npx tsx` is used as the default runtime).
|
||||||
|
|
||||||
|
Each TypeScript-based tool must follow a specific structure in order for Loki to properly compile and execute it:
|
||||||
|
|
||||||
|
* The tool must be a TypeScript file with a `.ts` file extension.
|
||||||
|
* The tool must have an `export function run(...)` that serves as the entry point for the tool.
|
||||||
|
* Non-exported functions are ignored by the compiler and can be used as private helpers.
|
||||||
|
* The `run` function must accept flat parameters that define the inputs for the tool.
|
||||||
|
* Always use type annotations to specify the data type of each parameter.
|
||||||
|
* Use `param?: type` or `type | null` to indicate optional parameters.
|
||||||
|
* Use `param: type = value` for parameters with default values.
|
||||||
|
* The `run` function must return a `string` (or `Promise<string>` for async functions).
|
||||||
|
* For TypeScript, the return value is automatically written to the `LLM_OUTPUT` environment variable, so there's
|
||||||
|
no need to explicitly write to the environment variable within the function.
|
||||||
|
* The function must have a JSDoc comment that describes the tool and its parameters.
|
||||||
|
* Each parameter should be documented using `@param name - description` tags.
|
||||||
|
* These descriptions are passed to the LLM as the tool description, letting the LLM know what the tool does and
|
||||||
|
how to use it.
|
||||||
|
* Async functions (`export async function run(...)`) are fully supported and handled transparently.
|
||||||
|
|
||||||
|
**Supported Parameter Types:**
|
||||||
|
|
||||||
|
| TypeScript Type | JSON Schema | Notes |
|
||||||
|
|-------------------|--------------------------------------------------|-----------------------------|
|
||||||
|
| `string` | `{"type": "string"}` | Required string |
|
||||||
|
| `number` | `{"type": "number"}` | Required number |
|
||||||
|
| `boolean` | `{"type": "boolean"}` | Required boolean |
|
||||||
|
| `string[]` | `{"type": "array", "items": {"type": "string"}}` | Array (bracket syntax) |
|
||||||
|
| `Array<string>` | `{"type": "array", "items": {"type": "string"}}` | Array (generic syntax) |
|
||||||
|
| `"foo" \| "bar"` | `{"type": "string", "enum": ["foo", "bar"]}` | String enum (literal union) |
|
||||||
|
| `param?: string` | `{"type": "string"}` (not required) | Optional via question mark |
|
||||||
|
| `string \| null` | `{"type": "string"}` (not required) | Optional via null union |
|
||||||
|
| `param = "value"` | `{"type": "string"}` (not required) | Optional via default value |
|
||||||
|
|
||||||
|
**Unsupported Patterns (will produce a compile error):**
|
||||||
|
|
||||||
|
* Rest parameters (`...args: string[]`)
|
||||||
|
* Destructured object parameters (`{ a, b }: { a: string, b: string }`)
|
||||||
|
* Arrow functions (`const run = (x: string) => ...`)
|
||||||
|
* Function expressions (`const run = function(x: string) { ... }`)
|
||||||
|
|
||||||
|
Only `export function` declarations are recognized. Non-exported functions are invisible to the compiler.
|
||||||
|
|
||||||
|
Below is the [`demo_ts.ts`](../../assets/functions/tools/demo_ts.ts) tool definition that comes pre-packaged with
|
||||||
|
Loki and demonstrates how to create a TypeScript-based tool:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Demonstrates all supported TypeScript parameter types and variations.
|
||||||
|
*
|
||||||
|
* @param string - A required string property
|
||||||
|
* @param string_enum - A required string property constrained to specific values
|
||||||
|
* @param boolean - A required boolean property
|
||||||
|
* @param number - A required number property
|
||||||
|
* @param array_bracket - A required string array using bracket syntax
|
||||||
|
* @param array_generic - A required string array using generic syntax
|
||||||
|
* @param string_optional - An optional string using the question mark syntax
|
||||||
|
* @param string_nullable - An optional string using the union-with-null syntax
|
||||||
|
* @param number_with_default - An optional number with a default value
|
||||||
|
* @param boolean_with_default - An optional boolean with a default value
|
||||||
|
* @param string_with_default - An optional string with a default value
|
||||||
|
* @param array_optional - An optional string array using the question mark syntax
|
||||||
|
*/
|
||||||
|
export function run(
|
||||||
|
string: string,
|
||||||
|
string_enum: "foo" | "bar",
|
||||||
|
boolean: boolean,
|
||||||
|
number: number,
|
||||||
|
array_bracket: string[],
|
||||||
|
array_generic: Array<string>,
|
||||||
|
string_optional?: string,
|
||||||
|
string_nullable: string | null = null,
|
||||||
|
number_with_default: number = 42,
|
||||||
|
boolean_with_default: boolean = true,
|
||||||
|
string_with_default: string = "hello",
|
||||||
|
array_optional?: string[],
|
||||||
|
): string {
|
||||||
|
const parts = [
|
||||||
|
`string: ${string}`,
|
||||||
|
`string_enum: ${string_enum}`,
|
||||||
|
`boolean: ${boolean}`,
|
||||||
|
`number: ${number}`,
|
||||||
|
`array_bracket: ${JSON.stringify(array_bracket)}`,
|
||||||
|
`array_generic: ${JSON.stringify(array_generic)}`,
|
||||||
|
`string_optional: ${string_optional}`,
|
||||||
|
`string_nullable: ${string_nullable}`,
|
||||||
|
`number_with_default: ${number_with_default}`,
|
||||||
|
`boolean_with_default: ${boolean_with_default}`,
|
||||||
|
`string_with_default: ${string_with_default}`,
|
||||||
|
`array_optional: ${JSON.stringify(array_optional)}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
if (key.startsWith("LLM_")) {
|
||||||
|
parts.push(`${key}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Runtime
|
||||||
|
By default, Loki uses the following runtimes to execute tools:
|
||||||
|
|
||||||
|
| Language | Default Runtime | Requirement |
|
||||||
|
|------------|-----------------|--------------------------------|
|
||||||
|
| Python | `python` | Python 3 on `$PATH` |
|
||||||
|
| TypeScript | `npx tsx` | Node.js + tsx (`npm i -g tsx`) |
|
||||||
|
| Bash | `bash` | Bash on `$PATH` |
|
||||||
|
|
||||||
|
You can override the runtime for Python and TypeScript tools using a **shebang line** (`#!`) at the top of your
|
||||||
|
script. Loki reads the first line of each tool file; if it starts with `#!`, the specified interpreter is used instead
|
||||||
|
of the default.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3.11
|
||||||
|
# This Python tool will be executed with python3.11 instead of the default `python`
|
||||||
|
|
||||||
|
def run(name: str):
|
||||||
|
"""Greet someone.
|
||||||
|
Args:
|
||||||
|
name: The name to greet
|
||||||
|
"""
|
||||||
|
return f"Hello, {name}!"
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
// This TypeScript tool will be executed with Bun instead of the default `npx tsx`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Greet someone.
|
||||||
|
* @param name - The name to greet
|
||||||
|
*/
|
||||||
|
export function run(name: string): string {
|
||||||
|
return `Hello, ${name}!`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful for pinning a specific Python version, using an alternative TypeScript runtime like
|
||||||
|
[Bun](https://bun.sh/) or [Deno](https://deno.com/), or working with virtual environments.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ be enabled/disabled can be found in the [Configuration](#configuration) section
|
|||||||
|-------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------|
|
|-------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------|
|
||||||
| [`demo_py.py`](../../assets/functions/tools/demo_py.py) | Demonstrates how to create a tool using Python and how to use comments. | 🔴 |
|
| [`demo_py.py`](../../assets/functions/tools/demo_py.py) | Demonstrates how to create a tool using Python and how to use comments. | 🔴 |
|
||||||
| [`demo_sh.sh`](../../assets/functions/tools/demo_sh.sh) | Demonstrate how to create a tool using Bash and how to use comment tags. | 🔴 |
|
| [`demo_sh.sh`](../../assets/functions/tools/demo_sh.sh) | Demonstrate how to create a tool using Bash and how to use comment tags. | 🔴 |
|
||||||
|
| [`demo_ts.ts`](../../assets/functions/tools/demo_ts.ts) | Demonstrates how to create a tool using TypeScript and how to use JSDoc comments. | 🔴 |
|
||||||
| [`execute_command.sh`](../../assets/functions/tools/execute_command.sh) | Execute the shell command. | 🟢 |
|
| [`execute_command.sh`](../../assets/functions/tools/execute_command.sh) | Execute the shell command. | 🟢 |
|
||||||
| [`execute_py_code.py`](../../assets/functions/tools/execute_py_code.py) | Execute the given Python code. | 🔴 |
|
| [`execute_py_code.py`](../../assets/functions/tools/execute_py_code.py) | Execute the given Python code. | 🔴 |
|
||||||
| [`execute_sql_code.sh`](../../assets/functions/tools/execute_sql_code.sh) | Execute SQL code. | 🔴 |
|
| [`execute_sql_code.sh`](../../assets/functions/tools/execute_sql_code.sh) | Execute SQL code. | 🔴 |
|
||||||
@@ -49,6 +50,7 @@ be enabled/disabled can be found in the [Configuration](#configuration) section
|
|||||||
| [`get_current_time.sh`](../../assets/functions/tools/get_current_time.sh) | Get the current time. | 🟢 |
|
| [`get_current_time.sh`](../../assets/functions/tools/get_current_time.sh) | Get the current time. | 🟢 |
|
||||||
| [`get_current_weather.py`](../../assets/functions/tools/get_current_weather.py) | Get the current weather in a given location (Python implementation) | 🔴 |
|
| [`get_current_weather.py`](../../assets/functions/tools/get_current_weather.py) | Get the current weather in a given location (Python implementation) | 🔴 |
|
||||||
| [`get_current_weather.sh`](../../assets/functions/tools/get_current_weather.sh) | Get the current weather in a given location. | 🟢 |
|
| [`get_current_weather.sh`](../../assets/functions/tools/get_current_weather.sh) | Get the current weather in a given location. | 🟢 |
|
||||||
|
| [`get_current_weather.ts`](../../assets/functions/tools/get_current_weather.ts) | Get the current weather in a given location (TypeScript implementation) | 🔴 |
|
||||||
| [`query_jira_issues.sh`](../../assets/functions/tools/query_jira_issues.sh) | Query for jira issues using a Jira Query Language (JQL) query. | 🟢 |
|
| [`query_jira_issues.sh`](../../assets/functions/tools/query_jira_issues.sh) | Query for jira issues using a Jira Query Language (JQL) query. | 🟢 |
|
||||||
| [`search_arxiv.sh`](../../assets/functions/tools/search_arxiv.sh) | Search arXiv using the given search query and return the top papers. | 🔴 |
|
| [`search_arxiv.sh`](../../assets/functions/tools/search_arxiv.sh) | Search arXiv using the given search query and return the top papers. | 🔴 |
|
||||||
| [`search_wikipedia.sh`](../../assets/functions/tools/search_wikipedia.sh) | Search Wikipedia using the given search query. <br>Use it to get detailed information about a public figure, interpretation of a <br>complex scientific concept or in-depth connectivity of a significant historical <br>event, etc. | 🔴 |
|
| [`search_wikipedia.sh`](../../assets/functions/tools/search_wikipedia.sh) | Search Wikipedia using the given search query. <br>Use it to get detailed information about a public figure, interpretation of a <br>complex scientific concept or in-depth connectivity of a significant historical <br>event, etc. | 🔴 |
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ async fn prepare_chat_completions(
|
|||||||
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
|
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
|
||||||
if !ready {
|
if !ready {
|
||||||
bail!(
|
bail!(
|
||||||
"OAuth configured but no tokens found for '{}'. Run: loki --authenticate {}",
|
"OAuth configured but no tokens found for '{}'. Run: 'loki --authenticate {}' or '.authenticate' in the REPL",
|
||||||
self_.name(),
|
self_.name(),
|
||||||
self_.name()
|
self_.name()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ async fn prepare_chat_completions(
|
|||||||
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
|
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
|
||||||
if !ready {
|
if !ready {
|
||||||
bail!(
|
bail!(
|
||||||
"OAuth configured but no tokens found for '{}'. Run: loki --authenticate {}",
|
"OAuth configured but no tokens found for '{}'. Run: 'loki --authenticate {}' or '.authenticate' in the REPL",
|
||||||
self_.name(),
|
self_.name(),
|
||||||
self_.name()
|
self_.name()
|
||||||
);
|
);
|
||||||
@@ -181,7 +181,7 @@ async fn prepare_embeddings(
|
|||||||
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
|
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
|
||||||
if !ready {
|
if !ready {
|
||||||
bail!(
|
bail!(
|
||||||
"OAuth configured but no tokens found for '{}'. Run: loki --authenticate {}",
|
"OAuth configured but no tokens found for '{}'. Run: 'loki --authenticate {}' or '.authenticate' in the REPL",
|
||||||
self_.name(),
|
self_.name(),
|
||||||
self_.name()
|
self_.name()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ mod tests {
|
|||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
use rand::Rng;
|
use rand::random_range;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -392,10 +392,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn split_chunks(text: &str) -> Vec<Vec<u8>> {
|
fn split_chunks(text: &str) -> Vec<Vec<u8>> {
|
||||||
let mut rng = rand::rng();
|
|
||||||
let len = text.len();
|
let len = text.len();
|
||||||
let cut1 = rng.random_range(1..len - 1);
|
let cut1 = random_range(1..len - 1);
|
||||||
let cut2 = rng.random_range(cut1 + 1..len);
|
let cut2 = random_range(cut1 + 1..len);
|
||||||
let chunk1 = text.as_bytes()[..cut1].to_vec();
|
let chunk1 = text.as_bytes()[..cut1].to_vec();
|
||||||
let chunk2 = text.as_bytes()[cut1..cut2].to_vec();
|
let chunk2 = text.as_bytes()[cut1..cut2].to_vec();
|
||||||
let chunk3 = text.as_bytes()[cut2..].to_vec();
|
let chunk3 = text.as_bytes()[cut2..].to_vec();
|
||||||
|
|||||||
@@ -476,6 +476,11 @@ impl Agent {
|
|||||||
self.todo_list.mark_done(id)
|
self.todo_list.mark_done(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn clear_todo_list(&mut self) {
|
||||||
|
self.todo_list.clear();
|
||||||
|
self.reset_continuation();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn continuation_prompt(&self) -> String {
|
pub fn continuation_prompt(&self) -> String {
|
||||||
self.config.continuation_prompt.clone().unwrap_or_else(|| {
|
self.config.continuation_prompt.clone().unwrap_or_else(|| {
|
||||||
formatdoc! {"
|
formatdoc! {"
|
||||||
|
|||||||
+1
-1
@@ -584,7 +584,7 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn agent_functions_file(name: &str) -> Result<PathBuf> {
|
pub fn agent_functions_file(name: &str) -> Result<PathBuf> {
|
||||||
let allowed = ["tools.sh", "tools.py", "tools.js"];
|
let allowed = ["tools.sh", "tools.py", "tools.ts", "tools.js"];
|
||||||
|
|
||||||
for entry in read_dir(Self::agent_data_dir(name))? {
|
for entry in read_dir(Self::agent_data_dir(name))? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ pub(in crate::config) const DEFAULT_TODO_INSTRUCTIONS: &str = indoc! {"
|
|||||||
- `todo__add`: Add individual tasks. Add all planned steps before starting work.
|
- `todo__add`: Add individual tasks. Add all planned steps before starting work.
|
||||||
- `todo__done`: Mark a task done by id. Call this immediately after completing each step.
|
- `todo__done`: Mark a task done by id. Call this immediately after completing each step.
|
||||||
- `todo__list`: Show the current todo list.
|
- `todo__list`: Show the current todo list.
|
||||||
|
- `todo__clear`: Clear the entire todo list and reset the goal. Use when the user cancels or changes direction.
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
- Always create a todo list before starting work.
|
- Always create a todo list before starting work.
|
||||||
- Mark each task done as soon as you finish it; do not batch.
|
- Mark each task done as soon as you finish it; do not batch.
|
||||||
|
- If the user cancels the current task or changes direction, call `todo__clear` immediately.
|
||||||
- If you stop with incomplete tasks, the system will automatically prompt you to continue."
|
- If you stop with incomplete tasks, the system will automatically prompt you to continue."
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ impl TodoList {
|
|||||||
self.todos.is_empty()
|
self.todos.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.goal.clear();
|
||||||
|
self.todos.clear();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_for_model(&self) -> String {
|
pub fn render_for_model(&self) -> String {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
if !self.goal.is_empty() {
|
if !self.goal.is_empty() {
|
||||||
@@ -149,6 +154,21 @@ mod tests {
|
|||||||
assert!(rendered.contains("○ 2. Map"));
|
assert!(rendered.contains("○ 2. Map"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear() {
|
||||||
|
let mut list = TodoList::new("Some goal");
|
||||||
|
list.add("Task 1");
|
||||||
|
list.add("Task 2");
|
||||||
|
list.mark_done(1);
|
||||||
|
assert!(!list.is_empty());
|
||||||
|
|
||||||
|
list.clear();
|
||||||
|
assert!(list.is_empty());
|
||||||
|
assert!(list.goal.is_empty());
|
||||||
|
assert_eq!(list.todos.len(), 0);
|
||||||
|
assert!(!list.has_incomplete());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_serialization_roundtrip() {
|
fn test_serialization_roundtrip() {
|
||||||
let mut list = TodoList::new("Roundtrip");
|
let mut list = TodoList::new("Roundtrip");
|
||||||
|
|||||||
+145
-48
@@ -12,7 +12,7 @@ use crate::mcp::{
|
|||||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
||||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
|
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
|
||||||
};
|
};
|
||||||
use crate::parsers::{bash, python};
|
use crate::parsers::{bash, python, typescript};
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use indoc::formatdoc;
|
use indoc::formatdoc;
|
||||||
@@ -53,6 +53,7 @@ enum BinaryType<'a> {
|
|||||||
enum Language {
|
enum Language {
|
||||||
Bash,
|
Bash,
|
||||||
Python,
|
Python,
|
||||||
|
TypeScript,
|
||||||
Unsupported,
|
Unsupported,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ impl From<&String> for Language {
|
|||||||
match s.to_lowercase().as_str() {
|
match s.to_lowercase().as_str() {
|
||||||
"sh" => Language::Bash,
|
"sh" => Language::Bash,
|
||||||
"py" => Language::Python,
|
"py" => Language::Python,
|
||||||
|
"ts" => Language::TypeScript,
|
||||||
_ => Language::Unsupported,
|
_ => Language::Unsupported,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +74,7 @@ impl Language {
|
|||||||
match self {
|
match self {
|
||||||
Language::Bash => "bash",
|
Language::Bash => "bash",
|
||||||
Language::Python => "python",
|
Language::Python => "python",
|
||||||
|
Language::TypeScript => "npx tsx",
|
||||||
Language::Unsupported => "sh",
|
Language::Unsupported => "sh",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,11 +83,32 @@ impl Language {
|
|||||||
match self {
|
match self {
|
||||||
Language::Bash => "sh",
|
Language::Bash => "sh",
|
||||||
Language::Python => "py",
|
Language::Python => "py",
|
||||||
|
Language::TypeScript => "ts",
|
||||||
_ => "sh",
|
_ => "sh",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_shebang_runtime(path: &Path) -> Option<String> {
|
||||||
|
let file = File::open(path).ok()?;
|
||||||
|
let reader = io::BufReader::new(file);
|
||||||
|
let first_line = io::BufRead::lines(reader).next()?.ok()?;
|
||||||
|
let shebang = first_line.strip_prefix("#!")?;
|
||||||
|
let cmd = shebang.trim();
|
||||||
|
if cmd.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(after_env) = cmd.strip_prefix("/usr/bin/env ") {
|
||||||
|
let runtime = after_env.trim();
|
||||||
|
if runtime.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(runtime.to_string())
|
||||||
|
} else {
|
||||||
|
Some(cmd.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn eval_tool_calls(
|
pub async fn eval_tool_calls(
|
||||||
config: &GlobalConfig,
|
config: &GlobalConfig,
|
||||||
mut calls: Vec<ToolCall>,
|
mut calls: Vec<ToolCall>,
|
||||||
@@ -473,6 +497,11 @@ impl Functions {
|
|||||||
file_name,
|
file_name,
|
||||||
tools_file_path.parent(),
|
tools_file_path.parent(),
|
||||||
),
|
),
|
||||||
|
Language::TypeScript => typescript::generate_typescript_declarations(
|
||||||
|
tool_file,
|
||||||
|
file_name,
|
||||||
|
tools_file_path.parent(),
|
||||||
|
),
|
||||||
Language::Unsupported => {
|
Language::Unsupported => {
|
||||||
bail!("Unsupported tool file extension: {}", language.as_ref())
|
bail!("Unsupported tool file extension: {}", language.as_ref())
|
||||||
}
|
}
|
||||||
@@ -513,7 +542,14 @@ impl Functions {
|
|||||||
bail!("Unsupported tool file extension: {}", language.as_ref());
|
bail!("Unsupported tool file extension: {}", language.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
Self::build_binaries(binary_name, language, BinaryType::Tool(agent_name))?;
|
let tool_path = Config::global_tools_dir().join(tool);
|
||||||
|
let custom_runtime = extract_shebang_runtime(&tool_path);
|
||||||
|
Self::build_binaries(
|
||||||
|
binary_name,
|
||||||
|
language,
|
||||||
|
BinaryType::Tool(agent_name),
|
||||||
|
custom_runtime.as_deref(),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -554,8 +590,9 @@ impl Functions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_agent_tool_binaries(name: &str) -> Result<()> {
|
fn build_agent_tool_binaries(name: &str) -> Result<()> {
|
||||||
|
let tools_file = Config::agent_functions_file(name)?;
|
||||||
let language = Language::from(
|
let language = Language::from(
|
||||||
&Config::agent_functions_file(name)?
|
&tools_file
|
||||||
.extension()
|
.extension()
|
||||||
.and_then(OsStr::to_str)
|
.and_then(OsStr::to_str)
|
||||||
.map(|s| s.to_lowercase())
|
.map(|s| s.to_lowercase())
|
||||||
@@ -568,7 +605,8 @@ impl Functions {
|
|||||||
bail!("Unsupported tool file extension: {}", language.as_ref());
|
bail!("Unsupported tool file extension: {}", language.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
Self::build_binaries(name, language, BinaryType::Agent)
|
let custom_runtime = extract_shebang_runtime(&tools_file);
|
||||||
|
Self::build_binaries(name, language, BinaryType::Agent, custom_runtime.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -576,6 +614,7 @@ impl Functions {
|
|||||||
binary_name: &str,
|
binary_name: &str,
|
||||||
language: Language,
|
language: Language,
|
||||||
binary_type: BinaryType,
|
binary_type: BinaryType,
|
||||||
|
custom_runtime: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use native::runtime;
|
use native::runtime;
|
||||||
let (binary_file, binary_script_file) = match binary_type {
|
let (binary_file, binary_script_file) = match binary_type {
|
||||||
@@ -613,6 +652,7 @@ impl Functions {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let content_template = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
|
let content_template = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
|
||||||
|
let to_script_path = |p: &str| -> String { p.replace('\\', "/") };
|
||||||
let content = match binary_type {
|
let content = match binary_type {
|
||||||
BinaryType::Tool(None) => {
|
BinaryType::Tool(None) => {
|
||||||
let root_dir = Config::functions_dir();
|
let root_dir = Config::functions_dir();
|
||||||
@@ -622,8 +662,8 @@ impl Functions {
|
|||||||
);
|
);
|
||||||
content_template
|
content_template
|
||||||
.replace("{function_name}", binary_name)
|
.replace("{function_name}", binary_name)
|
||||||
.replace("{root_dir}", &root_dir.to_string_lossy())
|
.replace("{root_dir}", &to_script_path(&root_dir.to_string_lossy()))
|
||||||
.replace("{tool_path}", &tool_path)
|
.replace("{tool_path}", &to_script_path(&tool_path))
|
||||||
}
|
}
|
||||||
BinaryType::Tool(Some(agent_name)) => {
|
BinaryType::Tool(Some(agent_name)) => {
|
||||||
let root_dir = Config::agent_data_dir(agent_name);
|
let root_dir = Config::agent_data_dir(agent_name);
|
||||||
@@ -633,16 +673,19 @@ impl Functions {
|
|||||||
);
|
);
|
||||||
content_template
|
content_template
|
||||||
.replace("{function_name}", binary_name)
|
.replace("{function_name}", binary_name)
|
||||||
.replace("{root_dir}", &root_dir.to_string_lossy())
|
.replace("{root_dir}", &to_script_path(&root_dir.to_string_lossy()))
|
||||||
.replace("{tool_path}", &tool_path)
|
.replace("{tool_path}", &to_script_path(&tool_path))
|
||||||
}
|
}
|
||||||
BinaryType::Agent => content_template
|
BinaryType::Agent => content_template
|
||||||
.replace("{agent_name}", binary_name)
|
.replace("{agent_name}", binary_name)
|
||||||
.replace("{config_dir}", &Config::config_dir().to_string_lossy()),
|
.replace(
|
||||||
|
"{config_dir}",
|
||||||
|
&to_script_path(&Config::config_dir().to_string_lossy()),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
.replace(
|
.replace(
|
||||||
"{prompt_utils_file}",
|
"{prompt_utils_file}",
|
||||||
&Config::bash_prompt_utils_file().to_string_lossy(),
|
&to_script_path(&Config::bash_prompt_utils_file().to_string_lossy()),
|
||||||
);
|
);
|
||||||
if binary_script_file.exists() {
|
if binary_script_file.exists() {
|
||||||
fs::remove_file(&binary_script_file)?;
|
fs::remove_file(&binary_script_file)?;
|
||||||
@@ -656,40 +699,48 @@ impl Functions {
|
|||||||
binary_file.display()
|
binary_file.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
let run = match language {
|
let run = if let Some(rt) = custom_runtime {
|
||||||
Language::Bash => {
|
rt.to_string()
|
||||||
let shell = runtime::bash_path().ok_or_else(|| anyhow!("Shell not found"))?;
|
} else {
|
||||||
format!("{shell} --noprofile --norc")
|
match language {
|
||||||
|
Language::Bash => {
|
||||||
|
let shell = runtime::bash_path().ok_or_else(|| anyhow!("Shell not found"))?;
|
||||||
|
format!("{shell} --noprofile --norc")
|
||||||
|
}
|
||||||
|
Language::Python if Path::new(".venv").exists() => {
|
||||||
|
let executable_path = env::current_dir()?
|
||||||
|
.join(".venv")
|
||||||
|
.join("Scripts")
|
||||||
|
.join("activate.bat");
|
||||||
|
let canonicalized_path = dunce::canonicalize(&executable_path)?;
|
||||||
|
format!(
|
||||||
|
"call \"{}\" && {}",
|
||||||
|
canonicalized_path.to_string_lossy(),
|
||||||
|
language.to_cmd()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Language::Python => {
|
||||||
|
let executable_path = which::which("python")
|
||||||
|
.or_else(|_| which::which("python3"))
|
||||||
|
.map_err(|_| anyhow!("Python executable not found in PATH"))?;
|
||||||
|
let canonicalized_path = dunce::canonicalize(&executable_path)?;
|
||||||
|
canonicalized_path.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
Language::TypeScript => {
|
||||||
|
let npx_path = which::which("npx").map_err(|_| {
|
||||||
|
anyhow!("npx executable not found in PATH (required for TypeScript tools)")
|
||||||
|
})?;
|
||||||
|
let canonicalized_path = dunce::canonicalize(&npx_path)?;
|
||||||
|
format!("{} tsx", canonicalized_path.to_string_lossy())
|
||||||
|
}
|
||||||
|
_ => bail!("Unsupported language: {}", language.as_ref()),
|
||||||
}
|
}
|
||||||
Language::Python if Path::new(".venv").exists() => {
|
|
||||||
let executable_path = env::current_dir()?
|
|
||||||
.join(".venv")
|
|
||||||
.join("Scripts")
|
|
||||||
.join("activate.bat");
|
|
||||||
let canonicalized_path = fs::canonicalize(&executable_path)?;
|
|
||||||
format!(
|
|
||||||
"call \"{}\" && {}",
|
|
||||||
canonicalized_path.to_string_lossy(),
|
|
||||||
language.to_cmd()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Language::Python => {
|
|
||||||
let executable_path = which::which("python")
|
|
||||||
.or_else(|_| which::which("python3"))
|
|
||||||
.map_err(|_| anyhow!("Python executable not found in PATH"))?;
|
|
||||||
let canonicalized_path = fs::canonicalize(&executable_path)?;
|
|
||||||
canonicalized_path.to_string_lossy().into_owned()
|
|
||||||
}
|
|
||||||
_ => bail!("Unsupported language: {}", language.as_ref()),
|
|
||||||
};
|
};
|
||||||
let bin_dir = binary_file
|
let bin_dir = binary_file
|
||||||
.parent()
|
.parent()
|
||||||
.expect("Failed to get parent directory of binary file")
|
.expect("Failed to get parent directory of binary file");
|
||||||
.canonicalize()?
|
let canonical_bin_dir = dunce::canonicalize(bin_dir)?.to_string_lossy().into_owned();
|
||||||
.to_string_lossy()
|
let wrapper_binary = dunce::canonicalize(&binary_script_file)?
|
||||||
.into_owned();
|
|
||||||
let wrapper_binary = binary_script_file
|
|
||||||
.canonicalize()?
|
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned();
|
.into_owned();
|
||||||
let content = formatdoc!(
|
let content = formatdoc!(
|
||||||
@@ -697,7 +748,7 @@ impl Functions {
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal
|
setlocal
|
||||||
|
|
||||||
set "bin_dir={bin_dir}"
|
set "bin_dir={canonical_bin_dir}"
|
||||||
|
|
||||||
{run} "{wrapper_binary}" %*"#,
|
{run} "{wrapper_binary}" %*"#,
|
||||||
);
|
);
|
||||||
@@ -713,6 +764,7 @@ impl Functions {
|
|||||||
binary_name: &str,
|
binary_name: &str,
|
||||||
language: Language,
|
language: Language,
|
||||||
binary_type: BinaryType,
|
binary_type: BinaryType,
|
||||||
|
custom_runtime: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use std::os::unix::prelude::PermissionsExt;
|
use std::os::unix::prelude::PermissionsExt;
|
||||||
|
|
||||||
@@ -741,7 +793,7 @@ impl Functions {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let content_template = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
|
let content_template = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
|
||||||
let content = match binary_type {
|
let mut content = match binary_type {
|
||||||
BinaryType::Tool(None) => {
|
BinaryType::Tool(None) => {
|
||||||
let root_dir = Config::functions_dir();
|
let root_dir = Config::functions_dir();
|
||||||
let tool_path = format!(
|
let tool_path = format!(
|
||||||
@@ -772,13 +824,44 @@ impl Functions {
|
|||||||
"{prompt_utils_file}",
|
"{prompt_utils_file}",
|
||||||
&Config::bash_prompt_utils_file().to_string_lossy(),
|
&Config::bash_prompt_utils_file().to_string_lossy(),
|
||||||
);
|
);
|
||||||
if binary_file.exists() {
|
|
||||||
fs::remove_file(&binary_file)?;
|
|
||||||
}
|
|
||||||
let mut file = File::create(&binary_file)?;
|
|
||||||
file.write_all(content.as_bytes())?;
|
|
||||||
|
|
||||||
fs::set_permissions(&binary_file, fs::Permissions::from_mode(0o755))?;
|
if let Some(rt) = custom_runtime
|
||||||
|
&& let Some(newline_pos) = content.find('\n')
|
||||||
|
{
|
||||||
|
content = format!("#!/usr/bin/env {rt}{}", &content[newline_pos..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if language == Language::TypeScript {
|
||||||
|
let bin_dir = binary_file
|
||||||
|
.parent()
|
||||||
|
.expect("Failed to get parent directory of binary file");
|
||||||
|
let script_file = bin_dir.join(format!("run-{binary_name}.ts"));
|
||||||
|
if script_file.exists() {
|
||||||
|
fs::remove_file(&script_file)?;
|
||||||
|
}
|
||||||
|
let mut sf = File::create(&script_file)?;
|
||||||
|
sf.write_all(content.as_bytes())?;
|
||||||
|
fs::set_permissions(&script_file, fs::Permissions::from_mode(0o755))?;
|
||||||
|
|
||||||
|
let ts_runtime = custom_runtime.unwrap_or("tsx");
|
||||||
|
let wrapper = format!(
|
||||||
|
"#!/bin/sh\nexec {ts_runtime} \"{}\" \"$@\"\n",
|
||||||
|
script_file.display()
|
||||||
|
);
|
||||||
|
if binary_file.exists() {
|
||||||
|
fs::remove_file(&binary_file)?;
|
||||||
|
}
|
||||||
|
let mut wf = File::create(&binary_file)?;
|
||||||
|
wf.write_all(wrapper.as_bytes())?;
|
||||||
|
fs::set_permissions(&binary_file, fs::Permissions::from_mode(0o755))?;
|
||||||
|
} else {
|
||||||
|
if binary_file.exists() {
|
||||||
|
fs::remove_file(&binary_file)?;
|
||||||
|
}
|
||||||
|
let mut file = File::create(&binary_file)?;
|
||||||
|
file.write_all(content.as_bytes())?;
|
||||||
|
fs::set_permissions(&binary_file, fs::Permissions::from_mode(0o755))?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1117,6 +1200,20 @@ pub fn run_llm_function(
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
let cmd_name = polyfill_cmd_name(&cmd_name, &bin_dirs);
|
let cmd_name = polyfill_cmd_name(&cmd_name, &bin_dirs);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
let cmd_args = {
|
||||||
|
let mut args = cmd_args;
|
||||||
|
if let Some(json_data) = args.pop() {
|
||||||
|
let tool_data_file = temp_file("-tool-data-", ".json");
|
||||||
|
fs::write(&tool_data_file, &json_data)?;
|
||||||
|
envs.insert(
|
||||||
|
"LLM_TOOL_DATA_FILE".into(),
|
||||||
|
tool_data_file.display().to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
args
|
||||||
|
};
|
||||||
|
|
||||||
envs.insert("CLICOLOR_FORCE".into(), "1".into());
|
envs.insert("CLICOLOR_FORCE".into(), "1".into());
|
||||||
envs.insert("FORCE_COLOR".into(), "1".into());
|
envs.insert("FORCE_COLOR".into(), "1".into());
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ pub fn todo_function_declarations() -> Vec<FunctionDeclaration> {
|
|||||||
},
|
},
|
||||||
agent: false,
|
agent: false,
|
||||||
},
|
},
|
||||||
|
FunctionDeclaration {
|
||||||
|
name: format!("{TODO_FUNCTION_PREFIX}clear"),
|
||||||
|
description: "Clear the entire todo list and reset the goal. Use when the current task has been canceled or invalidated.".to_string(),
|
||||||
|
parameters: JsonSchema {
|
||||||
|
type_value: Some("object".to_string()),
|
||||||
|
properties: Some(IndexMap::new()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
agent: false,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +166,17 @@ pub fn handle_todo_tool(config: &GlobalConfig, cmd_name: &str, args: &Value) ->
|
|||||||
None => bail!("No active agent"),
|
None => bail!("No active agent"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"clear" => {
|
||||||
|
let mut cfg = config.write();
|
||||||
|
let agent = cfg.agent.as_mut();
|
||||||
|
match agent {
|
||||||
|
Some(agent) => {
|
||||||
|
agent.clear_todo_list();
|
||||||
|
Ok(json!({"status": "ok", "message": "Todo list cleared"}))
|
||||||
|
}
|
||||||
|
None => bail!("No active agent"),
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => bail!("Unknown todo action: {action}"),
|
_ => bail!("Unknown todo action: {action}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ fn handle_direct_input(args: &Value) -> Result<Value> {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.ok_or_else(|| anyhow!("'question' is required"))?;
|
.ok_or_else(|| anyhow!("'question' is required"))?;
|
||||||
|
|
||||||
let answer = Text::new(question).prompt()?;
|
let answer = Text::new(&format!("{question}\nYour answer: ")).prompt()?;
|
||||||
|
|
||||||
Ok(json!({ "answer": answer }))
|
Ok(json!({ "answer": answer }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
use crate::function::{FunctionDeclaration, JsonSchema};
|
||||||
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tree_sitter::Node;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct Param {
|
||||||
|
pub name: String,
|
||||||
|
pub ty_hint: String,
|
||||||
|
pub required: bool,
|
||||||
|
pub default: Option<Value>,
|
||||||
|
pub doc_type: Option<String>,
|
||||||
|
pub doc_desc: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) trait ScriptedLanguage {
|
||||||
|
fn ts_language(&self) -> tree_sitter::Language;
|
||||||
|
|
||||||
|
fn lang_name(&self) -> &str;
|
||||||
|
|
||||||
|
fn find_functions<'a>(&self, root: Node<'a>, src: &str) -> Vec<(Node<'a>, Node<'a>)>;
|
||||||
|
|
||||||
|
fn function_name<'a>(&self, func_node: Node<'a>, src: &'a str) -> Result<&'a str>;
|
||||||
|
|
||||||
|
fn extract_description(
|
||||||
|
&self,
|
||||||
|
wrapper_node: Node<'_>,
|
||||||
|
func_node: Node<'_>,
|
||||||
|
src: &str,
|
||||||
|
) -> Option<String>;
|
||||||
|
|
||||||
|
fn extract_params(
|
||||||
|
&self,
|
||||||
|
func_node: Node<'_>,
|
||||||
|
src: &str,
|
||||||
|
description: &str,
|
||||||
|
) -> Result<Vec<Param>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_param(
|
||||||
|
name: &str,
|
||||||
|
mut ty: String,
|
||||||
|
mut required: bool,
|
||||||
|
default: Option<Value>,
|
||||||
|
) -> Param {
|
||||||
|
if ty.ends_with('?') {
|
||||||
|
ty.pop();
|
||||||
|
required = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Param {
|
||||||
|
name: name.to_string(),
|
||||||
|
ty_hint: ty,
|
||||||
|
required,
|
||||||
|
default,
|
||||||
|
doc_type: None,
|
||||||
|
doc_desc: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_parameters_schema(params: &[Param], _description: &str) -> JsonSchema {
|
||||||
|
let mut props: IndexMap<String, JsonSchema> = IndexMap::new();
|
||||||
|
let mut req: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for p in params {
|
||||||
|
let name = p.name.replace('-', "_");
|
||||||
|
let mut schema = JsonSchema::default();
|
||||||
|
|
||||||
|
let ty = if !p.ty_hint.is_empty() {
|
||||||
|
p.ty_hint.as_str()
|
||||||
|
} else if let Some(t) = &p.doc_type {
|
||||||
|
t.as_str()
|
||||||
|
} else {
|
||||||
|
"str"
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(d) = &p.doc_desc
|
||||||
|
&& !d.is_empty()
|
||||||
|
{
|
||||||
|
schema.description = Some(d.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_type_to_schema(ty, &mut schema);
|
||||||
|
|
||||||
|
if p.default.is_none() && p.required {
|
||||||
|
req.push(name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
props.insert(name, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("object".into()),
|
||||||
|
description: None,
|
||||||
|
properties: Some(props),
|
||||||
|
items: None,
|
||||||
|
any_of: None,
|
||||||
|
enum_value: None,
|
||||||
|
default: None,
|
||||||
|
required: if req.is_empty() { None } else { Some(req) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_type_to_schema(ty: &str, s: &mut JsonSchema) {
|
||||||
|
let t = ty.trim_end_matches('?');
|
||||||
|
if let Some(rest) = t.strip_prefix("list[") {
|
||||||
|
s.type_value = Some("array".into());
|
||||||
|
let inner = rest.trim_end_matches(']');
|
||||||
|
let mut item = JsonSchema::default();
|
||||||
|
|
||||||
|
apply_type_to_schema(inner, &mut item);
|
||||||
|
|
||||||
|
if item.type_value.is_none() {
|
||||||
|
item.type_value = Some("string".into());
|
||||||
|
}
|
||||||
|
s.items = Some(Box::new(item));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = t.strip_prefix("literal:") {
|
||||||
|
s.type_value = Some("string".into());
|
||||||
|
let vals = rest
|
||||||
|
.split('|')
|
||||||
|
.map(|x| x.trim().trim_matches('"').trim_matches('\'').to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !vals.is_empty() {
|
||||||
|
s.enum_value = Some(vals);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
s.type_value = Some(
|
||||||
|
match t {
|
||||||
|
"bool" => "boolean",
|
||||||
|
"int" => "integer",
|
||||||
|
"float" => "number",
|
||||||
|
"str" | "any" | "" => "string",
|
||||||
|
_ => "string",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn underscore(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() {
|
||||||
|
c.to_ascii_lowercase()
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.split('_')
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("_")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn node_text<'a>(node: Node<'_>, src: &'a str) -> Result<&'a str> {
|
||||||
|
node.utf8_text(src.as_bytes())
|
||||||
|
.map_err(|err| anyhow!("invalid utf-8 in source: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn named_child(node: Node<'_>, index: usize) -> Option<Node<'_>> {
|
||||||
|
let mut cursor = node.walk();
|
||||||
|
node.named_children(&mut cursor).nth(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generate_declarations<L: ScriptedLanguage>(
|
||||||
|
lang: &L,
|
||||||
|
src: &str,
|
||||||
|
file_name: &str,
|
||||||
|
is_tool: bool,
|
||||||
|
) -> Result<Vec<FunctionDeclaration>> {
|
||||||
|
let mut parser = tree_sitter::Parser::new();
|
||||||
|
let language = lang.ts_language();
|
||||||
|
parser.set_language(&language).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"failed to initialize {} tree-sitter parser",
|
||||||
|
lang.lang_name()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let tree = parser
|
||||||
|
.parse(src.as_bytes(), None)
|
||||||
|
.ok_or_else(|| anyhow!("failed to parse {}: {file_name}", lang.lang_name()))?;
|
||||||
|
|
||||||
|
if tree.root_node().has_error() {
|
||||||
|
bail!(
|
||||||
|
"failed to parse {}: syntax error in {file_name}",
|
||||||
|
lang.lang_name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (wrapper, func) in lang.find_functions(tree.root_node(), src) {
|
||||||
|
let func_name = lang.function_name(func, src)?;
|
||||||
|
|
||||||
|
if func_name.starts_with('_') && func_name != "_instructions" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if is_tool && func_name != "run" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let description = lang
|
||||||
|
.extract_description(wrapper, func, src)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let params = lang
|
||||||
|
.extract_params(func, src, &description)
|
||||||
|
.with_context(|| format!("in function '{func_name}' in {file_name}"))?;
|
||||||
|
let schema = build_parameters_schema(¶ms, &description);
|
||||||
|
|
||||||
|
let name = if is_tool && func_name == "run" {
|
||||||
|
underscore(file_name)
|
||||||
|
} else {
|
||||||
|
underscore(func_name)
|
||||||
|
};
|
||||||
|
|
||||||
|
let desc_trim = description.trim().to_string();
|
||||||
|
if desc_trim.is_empty() {
|
||||||
|
bail!("Missing or empty description on function: {func_name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(FunctionDeclaration {
|
||||||
|
name,
|
||||||
|
description: desc_trim,
|
||||||
|
parameters: schema,
|
||||||
|
agent: !is_tool,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
pub(crate) mod bash;
|
pub(crate) mod bash;
|
||||||
|
pub(crate) mod common;
|
||||||
pub(crate) mod python;
|
pub(crate) mod python;
|
||||||
|
pub(crate) mod typescript;
|
||||||
|
|||||||
+680
-334
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,789 @@
|
|||||||
|
use crate::function::FunctionDeclaration;
|
||||||
|
use crate::parsers::common::{self, Param, ScriptedLanguage};
|
||||||
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::Path;
|
||||||
|
use tree_sitter::Node;
|
||||||
|
|
||||||
|
pub(crate) struct TypeScriptLanguage;
|
||||||
|
|
||||||
|
impl ScriptedLanguage for TypeScriptLanguage {
|
||||||
|
fn ts_language(&self) -> tree_sitter::Language {
|
||||||
|
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lang_name(&self) -> &str {
|
||||||
|
"typescript"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_functions<'a>(&self, root: Node<'a>, _src: &str) -> Vec<(Node<'a>, Node<'a>)> {
|
||||||
|
let mut cursor = root.walk();
|
||||||
|
root.named_children(&mut cursor)
|
||||||
|
.filter_map(|stmt| match stmt.kind() {
|
||||||
|
"export_statement" => unwrap_exported_function(stmt).map(|fd| (stmt, fd)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn function_name<'a>(&self, func_node: Node<'a>, src: &'a str) -> Result<&'a str> {
|
||||||
|
let name_node = func_node
|
||||||
|
.child_by_field_name("name")
|
||||||
|
.ok_or_else(|| anyhow!("function_declaration missing name"))?;
|
||||||
|
common::node_text(name_node, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_description(
|
||||||
|
&self,
|
||||||
|
wrapper_node: Node<'_>,
|
||||||
|
func_node: Node<'_>,
|
||||||
|
src: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let text = jsdoc_text(wrapper_node, func_node, src)?;
|
||||||
|
let lines = clean_jsdoc_lines(text);
|
||||||
|
let mut description = Vec::new();
|
||||||
|
for line in lines {
|
||||||
|
if line.starts_with('@') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
description.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let description = description.join("\n").trim().to_string();
|
||||||
|
(!description.is_empty()).then_some(description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_params(
|
||||||
|
&self,
|
||||||
|
func_node: Node<'_>,
|
||||||
|
src: &str,
|
||||||
|
_description: &str,
|
||||||
|
) -> Result<Vec<Param>> {
|
||||||
|
let parameters = func_node
|
||||||
|
.child_by_field_name("parameters")
|
||||||
|
.ok_or_else(|| anyhow!("function_declaration missing parameters"))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut cursor = parameters.walk();
|
||||||
|
|
||||||
|
for param in parameters.named_children(&mut cursor) {
|
||||||
|
match param.kind() {
|
||||||
|
"required_parameter" | "optional_parameter" => {
|
||||||
|
let name = parameter_name(param, src)?;
|
||||||
|
let ty = get_arg_type(param.child_by_field_name("type"), src)?;
|
||||||
|
let required = param.kind() == "required_parameter"
|
||||||
|
&& param.child_by_field_name("value").is_none();
|
||||||
|
let default = param.child_by_field_name("value").map(|_| Value::Null);
|
||||||
|
out.push(common::build_param(name, ty, required, default));
|
||||||
|
}
|
||||||
|
"rest_parameter" => {
|
||||||
|
let line = param.start_position().row + 1;
|
||||||
|
bail!("line {line}: rest parameters (...) are not supported in tool functions")
|
||||||
|
}
|
||||||
|
"object_pattern" => {
|
||||||
|
let line = param.start_position().row + 1;
|
||||||
|
bail!(
|
||||||
|
"line {line}: destructured object parameters (e.g. '{{ a, b }}: {{ a: string }}') \
|
||||||
|
are not supported in tool functions. Use flat parameters instead (e.g. 'a: string, b: string')."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
let line = param.start_position().row + 1;
|
||||||
|
bail!("line {line}: unsupported parameter type: {other}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wrapper = match func_node.parent() {
|
||||||
|
Some(parent) if parent.kind() == "export_statement" => parent,
|
||||||
|
_ => func_node,
|
||||||
|
};
|
||||||
|
if let Some(doc) = jsdoc_text(wrapper, func_node, src) {
|
||||||
|
let meta = parse_jsdoc_params(doc);
|
||||||
|
for p in &mut out {
|
||||||
|
if let Some(desc) = meta.get(&p.name)
|
||||||
|
&& !desc.is_empty()
|
||||||
|
{
|
||||||
|
p.doc_desc = Some(desc.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_typescript_declarations(
|
||||||
|
mut tool_file: File,
|
||||||
|
file_name: &str,
|
||||||
|
parent: Option<&Path>,
|
||||||
|
) -> Result<Vec<FunctionDeclaration>> {
|
||||||
|
let mut src = String::new();
|
||||||
|
tool_file
|
||||||
|
.read_to_string(&mut src)
|
||||||
|
.with_context(|| format!("Failed to load script at '{tool_file:?}'"))?;
|
||||||
|
|
||||||
|
let is_tool = parent
|
||||||
|
.and_then(|p| p.file_name())
|
||||||
|
.is_some_and(|n| n == "tools");
|
||||||
|
|
||||||
|
common::generate_declarations(&TypeScriptLanguage, &src, file_name, is_tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unwrap_exported_function(node: Node<'_>) -> Option<Node<'_>> {
|
||||||
|
node.child_by_field_name("declaration")
|
||||||
|
.filter(|child| child.kind() == "function_declaration")
|
||||||
|
.or_else(|| {
|
||||||
|
let mut cursor = node.walk();
|
||||||
|
node.named_children(&mut cursor)
|
||||||
|
.find(|child| child.kind() == "function_declaration")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jsdoc_text<'a>(wrapper_node: Node<'_>, func_node: Node<'_>, src: &'a str) -> Option<&'a str> {
|
||||||
|
wrapper_node
|
||||||
|
.prev_named_sibling()
|
||||||
|
.or_else(|| func_node.prev_named_sibling())
|
||||||
|
.filter(|node| node.kind() == "comment")
|
||||||
|
.and_then(|node| common::node_text(node, src).ok())
|
||||||
|
.filter(|text| text.trim_start().starts_with("/**"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_jsdoc_lines(doc: &str) -> Vec<String> {
|
||||||
|
let trimmed = doc.trim();
|
||||||
|
let inner = trimmed
|
||||||
|
.strip_prefix("/**")
|
||||||
|
.unwrap_or(trimmed)
|
||||||
|
.strip_suffix("*/")
|
||||||
|
.unwrap_or(trimmed);
|
||||||
|
|
||||||
|
inner
|
||||||
|
.lines()
|
||||||
|
.map(|line| {
|
||||||
|
let line = line.trim();
|
||||||
|
let line = line.strip_prefix('*').unwrap_or(line).trim_start();
|
||||||
|
line.to_string()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_jsdoc_params(doc: &str) -> IndexMap<String, String> {
|
||||||
|
let mut out = IndexMap::new();
|
||||||
|
|
||||||
|
for line in clean_jsdoc_lines(doc) {
|
||||||
|
let Some(rest) = line.strip_prefix("@param") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut rest = rest.trim();
|
||||||
|
if rest.starts_with('{')
|
||||||
|
&& let Some(end) = rest.find('}')
|
||||||
|
{
|
||||||
|
rest = rest[end + 1..].trim_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
if rest.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||||
|
let mut name = rest[..name_end].trim();
|
||||||
|
if let Some(stripped) = name.strip_suffix('?') {
|
||||||
|
name = stripped;
|
||||||
|
}
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut desc = rest[name_end..].trim();
|
||||||
|
if let Some(stripped) = desc.strip_prefix('-') {
|
||||||
|
desc = stripped.trim_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
out.insert(name.to_string(), desc.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameter_name<'a>(node: Node<'_>, src: &'a str) -> Result<&'a str> {
|
||||||
|
if let Some(name) = node.child_by_field_name("name") {
|
||||||
|
return match name.kind() {
|
||||||
|
"identifier" => common::node_text(name, src),
|
||||||
|
"rest_pattern" => {
|
||||||
|
let line = node.start_position().row + 1;
|
||||||
|
bail!("line {line}: rest parameters (...) are not supported in tool functions")
|
||||||
|
}
|
||||||
|
"object_pattern" | "array_pattern" => {
|
||||||
|
let line = node.start_position().row + 1;
|
||||||
|
bail!(
|
||||||
|
"line {line}: destructured parameters are not supported in tool functions. \
|
||||||
|
Use flat parameters instead (e.g. 'a: string, b: string')."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
let line = node.start_position().row + 1;
|
||||||
|
bail!("line {line}: unsupported parameter type: {other}")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let pattern = node
|
||||||
|
.child_by_field_name("pattern")
|
||||||
|
.ok_or_else(|| anyhow!("parameter missing pattern"))?;
|
||||||
|
|
||||||
|
match pattern.kind() {
|
||||||
|
"identifier" => common::node_text(pattern, src),
|
||||||
|
"rest_pattern" => {
|
||||||
|
let line = node.start_position().row + 1;
|
||||||
|
bail!("line {line}: rest parameters (...) are not supported in tool functions")
|
||||||
|
}
|
||||||
|
"object_pattern" | "array_pattern" => {
|
||||||
|
let line = node.start_position().row + 1;
|
||||||
|
bail!(
|
||||||
|
"line {line}: destructured parameters are not supported in tool functions. \
|
||||||
|
Use flat parameters instead (e.g. 'a: string, b: string')."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
let line = node.start_position().row + 1;
|
||||||
|
bail!("line {line}: unsupported parameter type: {other}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_arg_type(annotation: Option<Node<'_>>, src: &str) -> Result<String> {
|
||||||
|
let Some(annotation) = annotation else {
|
||||||
|
return Ok(String::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
match annotation.kind() {
|
||||||
|
"type_annotation" | "type" => get_arg_type(common::named_child(annotation, 0), src),
|
||||||
|
"predefined_type" => Ok(match common::node_text(annotation, src)? {
|
||||||
|
"string" => "str",
|
||||||
|
"number" => "float",
|
||||||
|
"boolean" => "bool",
|
||||||
|
"any" | "unknown" | "void" | "undefined" => "any",
|
||||||
|
_ => "any",
|
||||||
|
}
|
||||||
|
.to_string()),
|
||||||
|
"type_identifier" | "nested_type_identifier" => Ok("any".to_string()),
|
||||||
|
"generic_type" => {
|
||||||
|
let name = annotation
|
||||||
|
.child_by_field_name("name")
|
||||||
|
.ok_or_else(|| anyhow!("generic_type missing name"))?;
|
||||||
|
let type_name = common::node_text(name, src)?;
|
||||||
|
let type_args = annotation
|
||||||
|
.child_by_field_name("type_arguments")
|
||||||
|
.ok_or_else(|| anyhow!("generic_type missing type arguments"))?;
|
||||||
|
let inner = common::named_child(type_args, 0)
|
||||||
|
.ok_or_else(|| anyhow!("generic_type missing inner type"))?;
|
||||||
|
|
||||||
|
match type_name {
|
||||||
|
"Array" => Ok(format!("list[{}]", get_arg_type(Some(inner), src)?)),
|
||||||
|
_ => Ok("any".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"array_type" => {
|
||||||
|
let inner = common::named_child(annotation, 0)
|
||||||
|
.ok_or_else(|| anyhow!("array_type missing inner type"))?;
|
||||||
|
Ok(format!("list[{}]", get_arg_type(Some(inner), src)?))
|
||||||
|
}
|
||||||
|
"union_type" => resolve_union_type(annotation, src),
|
||||||
|
"literal_type" => resolve_literal_type(annotation, src),
|
||||||
|
"parenthesized_type" => get_arg_type(common::named_child(annotation, 0), src),
|
||||||
|
_ => Ok("any".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_union_type(annotation: Node<'_>, src: &str) -> Result<String> {
|
||||||
|
let members = flatten_union_members(annotation);
|
||||||
|
let has_null = members.iter().any(|member| is_nullish_type(*member, src));
|
||||||
|
|
||||||
|
let mut literal_values = Vec::new();
|
||||||
|
let mut all_string_literals = true;
|
||||||
|
for member in &members {
|
||||||
|
match string_literal_member(*member, src) {
|
||||||
|
Some(value) => literal_values.push(value),
|
||||||
|
None => {
|
||||||
|
all_string_literals = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_string_literals && !literal_values.is_empty() {
|
||||||
|
return Ok(format!("literal:{}", literal_values.join("|")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut first_non_null = None;
|
||||||
|
for member in members {
|
||||||
|
if is_nullish_type(member, src) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
first_non_null = Some(get_arg_type(Some(member), src)?);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ty = first_non_null.unwrap_or_else(|| "any".to_string());
|
||||||
|
if has_null && !ty.ends_with('?') {
|
||||||
|
ty.push('?');
|
||||||
|
}
|
||||||
|
Ok(ty)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten_union_members(node: Node<'_>) -> Vec<Node<'_>> {
|
||||||
|
let node = if node.kind() == "type" {
|
||||||
|
match common::named_child(node, 0) {
|
||||||
|
Some(inner) => inner,
|
||||||
|
None => return vec![],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node
|
||||||
|
};
|
||||||
|
|
||||||
|
if node.kind() != "union_type" {
|
||||||
|
return vec![node];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cursor = node.walk();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for child in node.named_children(&mut cursor) {
|
||||||
|
out.extend(flatten_union_members(child));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_literal_type(annotation: Node<'_>, src: &str) -> Result<String> {
|
||||||
|
let inner = common::named_child(annotation, 0)
|
||||||
|
.ok_or_else(|| anyhow!("literal_type missing inner literal"))?;
|
||||||
|
|
||||||
|
match inner.kind() {
|
||||||
|
"string" | "number" | "true" | "false" | "unary_expression" => {
|
||||||
|
Ok(format!("literal:{}", common::node_text(inner, src)?.trim()))
|
||||||
|
}
|
||||||
|
"null" | "undefined" => Ok("any".to_string()),
|
||||||
|
_ => Ok("any".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_literal_member(node: Node<'_>, src: &str) -> Option<String> {
|
||||||
|
let node = if node.kind() == "type" {
|
||||||
|
common::named_child(node, 0)?
|
||||||
|
} else {
|
||||||
|
node
|
||||||
|
};
|
||||||
|
|
||||||
|
if node.kind() != "literal_type" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner = common::named_child(node, 0)?;
|
||||||
|
if inner.kind() != "string" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(common::node_text(inner, src).ok()?.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_nullish_type(node: Node<'_>, src: &str) -> bool {
|
||||||
|
let node = if node.kind() == "type" {
|
||||||
|
match common::named_child(node, 0) {
|
||||||
|
Some(inner) => inner,
|
||||||
|
None => return false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node
|
||||||
|
};
|
||||||
|
|
||||||
|
match node.kind() {
|
||||||
|
"literal_type" => common::named_child(node, 0)
|
||||||
|
.is_some_and(|inner| matches!(inner.kind(), "null" | "undefined")),
|
||||||
|
"predefined_type" => common::node_text(node, src)
|
||||||
|
.map(|text| matches!(text, "undefined" | "void"))
|
||||||
|
.unwrap_or(false),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::function::JsonSchema;
|
||||||
|
use std::fs;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn parse_ts_source(
|
||||||
|
source: &str,
|
||||||
|
file_name: &str,
|
||||||
|
parent: &Path,
|
||||||
|
) -> Result<Vec<FunctionDeclaration>> {
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("time")
|
||||||
|
.as_nanos();
|
||||||
|
let path = std::env::temp_dir().join(format!("loki_ts_parser_{file_name}_{unique}.ts"));
|
||||||
|
fs::write(&path, source).expect("write");
|
||||||
|
let file = File::open(&path).expect("open");
|
||||||
|
let result = generate_typescript_declarations(file, file_name, Some(parent));
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn properties(schema: &JsonSchema) -> &IndexMap<String, JsonSchema> {
|
||||||
|
schema
|
||||||
|
.properties
|
||||||
|
.as_ref()
|
||||||
|
.expect("missing schema properties")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn property<'a>(schema: &'a JsonSchema, name: &str) -> &'a JsonSchema {
|
||||||
|
properties(schema)
|
||||||
|
.get(name)
|
||||||
|
.unwrap_or_else(|| panic!("missing property: {name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ts_tool_demo() {
|
||||||
|
let source = r#"
|
||||||
|
/**
|
||||||
|
* Demonstrates how to create a tool using TypeScript.
|
||||||
|
*
|
||||||
|
* @param query - The search query string
|
||||||
|
* @param format - Output format
|
||||||
|
* @param count - Maximum results to return
|
||||||
|
* @param verbose - Enable verbose output
|
||||||
|
* @param tags - List of tags to filter by
|
||||||
|
* @param language - Optional language filter
|
||||||
|
* @param extra_tags - Optional extra tags
|
||||||
|
*/
|
||||||
|
export function run(
|
||||||
|
query: string,
|
||||||
|
format: "json" | "csv" | "xml",
|
||||||
|
count: number,
|
||||||
|
verbose: boolean,
|
||||||
|
tags: string[],
|
||||||
|
language?: string,
|
||||||
|
extra_tags?: Array<string>,
|
||||||
|
): string {
|
||||||
|
return "result";
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let declarations = parse_ts_source(source, "demo_ts", Path::new("tools")).unwrap();
|
||||||
|
assert_eq!(declarations.len(), 1);
|
||||||
|
|
||||||
|
let decl = &declarations[0];
|
||||||
|
assert_eq!(decl.name, "demo_ts");
|
||||||
|
assert!(!decl.agent);
|
||||||
|
|
||||||
|
let params = &decl.parameters;
|
||||||
|
assert_eq!(params.type_value.as_deref(), Some("object"));
|
||||||
|
assert_eq!(
|
||||||
|
params.required.as_ref().unwrap(),
|
||||||
|
&vec![
|
||||||
|
"query".to_string(),
|
||||||
|
"format".to_string(),
|
||||||
|
"count".to_string(),
|
||||||
|
"verbose".to_string(),
|
||||||
|
"tags".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
property(params, "query").type_value.as_deref(),
|
||||||
|
Some("string")
|
||||||
|
);
|
||||||
|
|
||||||
|
let format = property(params, "format");
|
||||||
|
assert_eq!(format.type_value.as_deref(), Some("string"));
|
||||||
|
assert_eq!(
|
||||||
|
format.enum_value.as_ref().unwrap(),
|
||||||
|
&vec!["json".to_string(), "csv".to_string(), "xml".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
property(params, "count").type_value.as_deref(),
|
||||||
|
Some("number")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
property(params, "verbose").type_value.as_deref(),
|
||||||
|
Some("boolean")
|
||||||
|
);
|
||||||
|
|
||||||
|
let tags = property(params, "tags");
|
||||||
|
assert_eq!(tags.type_value.as_deref(), Some("array"));
|
||||||
|
assert_eq!(
|
||||||
|
tags.items.as_ref().unwrap().type_value.as_deref(),
|
||||||
|
Some("string")
|
||||||
|
);
|
||||||
|
|
||||||
|
let language = property(params, "language");
|
||||||
|
assert_eq!(language.type_value.as_deref(), Some("string"));
|
||||||
|
assert!(
|
||||||
|
!params
|
||||||
|
.required
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"language".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
let extra_tags = property(params, "extra_tags");
|
||||||
|
assert_eq!(extra_tags.type_value.as_deref(), Some("array"));
|
||||||
|
assert_eq!(
|
||||||
|
extra_tags.items.as_ref().unwrap().type_value.as_deref(),
|
||||||
|
Some("string")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!params
|
||||||
|
.required
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"extra_tags".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ts_tool_simple() {
|
||||||
|
let source = r#"
|
||||||
|
/**
|
||||||
|
* Execute the given code.
|
||||||
|
*
|
||||||
|
* @param code - The code to execute
|
||||||
|
*/
|
||||||
|
export function run(code: string): string {
|
||||||
|
return eval(code);
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let declarations = parse_ts_source(source, "execute_code", Path::new("tools")).unwrap();
|
||||||
|
assert_eq!(declarations.len(), 1);
|
||||||
|
|
||||||
|
let decl = &declarations[0];
|
||||||
|
assert_eq!(decl.name, "execute_code");
|
||||||
|
assert!(!decl.agent);
|
||||||
|
|
||||||
|
let params = &decl.parameters;
|
||||||
|
assert_eq!(params.required.as_ref().unwrap(), &vec!["code".to_string()]);
|
||||||
|
assert_eq!(
|
||||||
|
property(params, "code").type_value.as_deref(),
|
||||||
|
Some("string")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ts_agent_tools() {
|
||||||
|
let source = r#"
|
||||||
|
/** Get user info by ID */
|
||||||
|
export function get_user(id: string): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List all users */
|
||||||
|
export function list_users(): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let declarations = parse_ts_source(source, "tools", Path::new("demo")).unwrap();
|
||||||
|
assert_eq!(declarations.len(), 2);
|
||||||
|
assert_eq!(declarations[0].name, "get_user");
|
||||||
|
assert_eq!(declarations[1].name, "list_users");
|
||||||
|
assert!(declarations[0].agent);
|
||||||
|
assert!(declarations[1].agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ts_reject_rest_params() {
|
||||||
|
let source = r#"
|
||||||
|
/**
|
||||||
|
* Has rest params
|
||||||
|
*/
|
||||||
|
export function run(...args: string[]): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let err = parse_ts_source(source, "rest_params", Path::new("tools")).unwrap_err();
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(msg.contains("rest parameters"));
|
||||||
|
assert!(msg.contains("in function 'run'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ts_missing_jsdoc() {
|
||||||
|
let source = r#"
|
||||||
|
export function run(x: string): string {
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let err = parse_ts_source(source, "missing_jsdoc", Path::new("tools")).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("Missing or empty description on function: run")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ts_syntax_error() {
|
||||||
|
let source = "export function run(: broken";
|
||||||
|
let err = parse_ts_source(source, "syntax_error", Path::new("tools")).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("failed to parse typescript"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ts_underscore_skipped() {
|
||||||
|
let source = r#"
|
||||||
|
/** Private helper */
|
||||||
|
function _helper(): void {}
|
||||||
|
|
||||||
|
/** Public function */
|
||||||
|
export function do_stuff(): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let declarations = parse_ts_source(source, "tools", Path::new("demo")).unwrap();
|
||||||
|
assert_eq!(declarations.len(), 1);
|
||||||
|
assert_eq!(declarations[0].name, "do_stuff");
|
||||||
|
assert!(declarations[0].agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ts_non_exported_helpers_skipped() {
|
||||||
|
let source = r#"
|
||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
import { appendFileSync } from 'fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current weather in a given location
|
||||||
|
* @param location - The city
|
||||||
|
*/
|
||||||
|
export function get_current_weather(location: string): string {
|
||||||
|
return fetchSync("https://example.com/" + location);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchSync(url: string): string {
|
||||||
|
return "sunny";
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let declarations = parse_ts_source(source, "tools", Path::new("demo")).unwrap();
|
||||||
|
assert_eq!(declarations.len(), 1);
|
||||||
|
assert_eq!(declarations[0].name, "get_current_weather");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ts_instructions_not_skipped() {
|
||||||
|
let source = r#"
|
||||||
|
/** Help text for the agent */
|
||||||
|
export function _instructions(): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let declarations = parse_ts_source(source, "tools", Path::new("demo")).unwrap();
|
||||||
|
assert_eq!(declarations.len(), 1);
|
||||||
|
assert_eq!(declarations[0].name, "instructions");
|
||||||
|
assert!(declarations[0].agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ts_optional_with_null_union() {
|
||||||
|
let source = r#"
|
||||||
|
/**
|
||||||
|
* Fetch data with optional filter
|
||||||
|
*
|
||||||
|
* @param url - The URL to fetch
|
||||||
|
* @param filter - Optional filter string
|
||||||
|
*/
|
||||||
|
export function run(url: string, filter: string | null): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let declarations = parse_ts_source(source, "fetch_data", Path::new("tools")).unwrap();
|
||||||
|
let params = &declarations[0].parameters;
|
||||||
|
assert!(
|
||||||
|
params
|
||||||
|
.required
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"url".to_string())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!params
|
||||||
|
.required
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"filter".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
property(params, "filter").type_value.as_deref(),
|
||||||
|
Some("string")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ts_optional_with_default() {
|
||||||
|
let source = r#"
|
||||||
|
/**
|
||||||
|
* Search with limit
|
||||||
|
*
|
||||||
|
* @param query - Search query
|
||||||
|
* @param limit - Max results
|
||||||
|
*/
|
||||||
|
export function run(query: string, limit: number = 10): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let declarations =
|
||||||
|
parse_ts_source(source, "search_with_limit", Path::new("tools")).unwrap();
|
||||||
|
let params = &declarations[0].parameters;
|
||||||
|
assert!(
|
||||||
|
params
|
||||||
|
.required
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"query".to_string())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!params
|
||||||
|
.required
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"limit".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
property(params, "limit").type_value.as_deref(),
|
||||||
|
Some("number")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ts_shebang_parses() {
|
||||||
|
let source = r#"#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get weather
|
||||||
|
* @param location - The city
|
||||||
|
*/
|
||||||
|
export function run(location: string): string {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let result = parse_ts_source(source, "get_weather", Path::new("tools"));
|
||||||
|
eprintln!("shebang parse result: {result:?}");
|
||||||
|
assert!(result.is_ok(), "shebang should not cause parse failure");
|
||||||
|
let declarations = result.unwrap();
|
||||||
|
assert_eq!(declarations.len(), 1);
|
||||||
|
assert_eq!(declarations[0].name, "get_weather");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,12 +111,14 @@ fn create_suggestion(value: &str, description: &str, span: Span) -> Suggestion {
|
|||||||
Some(description.to_string())
|
Some(description.to_string())
|
||||||
};
|
};
|
||||||
Suggestion {
|
Suggestion {
|
||||||
|
display_override: None,
|
||||||
value: value.to_string(),
|
value: value.to_string(),
|
||||||
description,
|
description,
|
||||||
style: None,
|
style: None,
|
||||||
extra: None,
|
extra: None,
|
||||||
span,
|
span,
|
||||||
append_whitespace: false,
|
append_whitespace: false,
|
||||||
|
match_indices: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -33,7 +33,7 @@ use std::{env, mem, process};
|
|||||||
|
|
||||||
const MENU_NAME: &str = "completion_menu";
|
const MENU_NAME: &str = "completion_menu";
|
||||||
|
|
||||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 38]> = LazyLock::new(|| {
|
static REPL_COMMANDS: LazyLock<[ReplCommand; 39]> = LazyLock::new(|| {
|
||||||
[
|
[
|
||||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||||
@@ -137,6 +137,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 38]> = LazyLock::new(|| {
|
|||||||
"Leave agent",
|
"Leave agent",
|
||||||
AssertState::True(StateFlags::AGENT),
|
AssertState::True(StateFlags::AGENT),
|
||||||
),
|
),
|
||||||
|
ReplCommand::new(
|
||||||
|
".clear todo",
|
||||||
|
"Clear the todo list and stop auto-continuation",
|
||||||
|
AssertState::True(StateFlags::AGENT),
|
||||||
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".rag",
|
".rag",
|
||||||
"Initialize or access RAG",
|
"Initialize or access RAG",
|
||||||
@@ -804,6 +809,25 @@ pub async fn run_repl_command(
|
|||||||
Some("messages") => {
|
Some("messages") => {
|
||||||
bail!("Use '.empty session' instead");
|
bail!("Use '.empty session' instead");
|
||||||
}
|
}
|
||||||
|
Some("todo") => {
|
||||||
|
let mut cfg = config.write();
|
||||||
|
match cfg.agent.as_mut() {
|
||||||
|
Some(agent) => {
|
||||||
|
if !agent.auto_continue_enabled() {
|
||||||
|
bail!(
|
||||||
|
"The todo system is not enabled for this agent. Set 'auto_continue: true' in the agent's config.yaml to enable it."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if agent.todo_list().is_empty() {
|
||||||
|
println!("Todo list is already empty.");
|
||||||
|
} else {
|
||||||
|
agent.clear_todo_list();
|
||||||
|
println!("Todo list cleared.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => bail!("No active agent"),
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => unknown_command()?,
|
_ => unknown_command()?,
|
||||||
},
|
},
|
||||||
".vault" => match split_first_arg(args) {
|
".vault" => match split_first_arg(args) {
|
||||||
|
|||||||
Reference in New Issue
Block a user