Compare commits
12 Commits
f865892c28
...
langchain
| Author | SHA1 | Date | |
|---|---|---|---|
|
9bab6a0c2d
|
|||
| ff3419a714 | |||
|
a5899da4fb
|
|||
|
dedcef8ac5
|
|||
|
d658f1d2fe
|
|||
|
6b4a45874f
|
|||
|
7839e1dbd9
|
|||
|
78c3932f36
|
|||
|
11334149b0
|
|||
|
4caa035528
|
|||
|
f30e81af08
|
|||
|
4c75655f58
|
Generated
+66
-27
@@ -205,7 +205,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
@@ -314,29 +314,17 @@ version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
dependencies = [
|
||||
"aws-lc-sys 0.39.1",
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.37.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
@@ -1038,7 +1026,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1048,7 +1047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"chacha20 0.9.1",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
@@ -1300,6 +1299,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32c"
|
||||
version = "0.6.8"
|
||||
@@ -2114,6 +2122,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@@ -2132,15 +2141,15 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "gman"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c3a428900217107275faf709b30c00f37e1112ec2b75742987b5ca88700eaa"
|
||||
checksum = "742225eb41061a0938aa0924ce8d08a1ec48875789b72ce3f0cb02eda52ab1db"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
"aws-lc-sys 0.37.1",
|
||||
"aws-lc-sys",
|
||||
"aws-sdk-secretsmanager",
|
||||
"azure_core",
|
||||
"azure_identity",
|
||||
@@ -3086,7 +3095,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"path-absolutize",
|
||||
"pretty_assertions",
|
||||
"rand 0.9.2",
|
||||
"rand 0.10.0",
|
||||
"rayon",
|
||||
"reedline",
|
||||
"reqwest",
|
||||
@@ -3109,7 +3118,9 @@ dependencies = [
|
||||
"tokio-graceful",
|
||||
"tokio-stream",
|
||||
"tree-sitter",
|
||||
"tree-sitter-language",
|
||||
"tree-sitter-python",
|
||||
"tree-sitter-typescript",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"url",
|
||||
@@ -3975,7 +3986,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
@@ -4235,6 +4246,17 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
|
||||
dependencies = [
|
||||
"chacha20 0.10.0",
|
||||
"getrandom 0.4.2",
|
||||
"rand_core 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
@@ -4263,6 +4285,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
@@ -5010,7 +5038,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -5829,13 +5857,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.24.7"
|
||||
version = "0.26.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75"
|
||||
checksum = "887bd495d0582c5e3e0d8ece2233666169fa56a9644d172fc22ad179ab2d0538"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"serde_json",
|
||||
"streaming-iterator",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
@@ -5848,9 +5877,19 @@ checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-python"
|
||||
version = "0.23.6"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
|
||||
checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-typescript"
|
||||
version = "0.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
|
||||
+6
-4
@@ -91,14 +91,16 @@ strum_macros = "0.27.2"
|
||||
indoc = "2.0.6"
|
||||
rmcp = { version = "0.16.0", features = ["client", "transport-child-process"] }
|
||||
num_cpus = "1.17.0"
|
||||
tree-sitter = "0.24"
|
||||
tree-sitter-python = "0.23"
|
||||
tree-sitter = "0.26.8"
|
||||
tree-sitter-language = "0.1"
|
||||
tree-sitter-python = "0.25.0"
|
||||
tree-sitter-typescript = "0.23"
|
||||
colored = "3.0.0"
|
||||
clap_complete = { version = "4.5.58", features = ["unstable-dynamic"] }
|
||||
gman = "0.3.0"
|
||||
gman = "0.4.1"
|
||||
clap_complete_nushell = "4.5.9"
|
||||
open = "5"
|
||||
rand = "0.9.0"
|
||||
rand = { version = "0.10.0", features = ["default"] }
|
||||
url = "2.5.8"
|
||||
|
||||
[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
|
||||
* [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 TypeScript Tools](./docs/function-calling/CUSTOM-TOOLS.md#custom-typescript-based-tools)
|
||||
* [Create Custom Bash Tools](./docs/function-calling/CUSTOM-BASH-TOOLS.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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
|
||||
def run(
|
||||
string: str,
|
||||
string_enum: Literal["foo", "bar"],
|
||||
@@ -9,26 +10,38 @@ def run(
|
||||
number: float,
|
||||
array: List[str],
|
||||
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,
|
||||
):
|
||||
"""Demonstrates how to create a tool using Python and how to use comments.
|
||||
"""Demonstrates all supported Python parameter types and variations.
|
||||
Args:
|
||||
string: Define a required string property
|
||||
string_enum: Define a required string property with enum
|
||||
boolean: Define a required boolean property
|
||||
integer: Define a required integer property
|
||||
number: Define a required number property
|
||||
array: Define a required string array property
|
||||
string_optional: Define an optional string property
|
||||
array_optional: Define an optional string array property
|
||||
string: A required string property
|
||||
string_enum: A required string property constrained to specific values
|
||||
boolean: A required boolean property
|
||||
integer: A required integer property
|
||||
number: A required number (float) property
|
||||
array: A required string array property
|
||||
string_optional: An optional string property (Optional[str] with None default)
|
||||
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}
|
||||
string_enum: {string_enum}
|
||||
string_optional: {string_optional}
|
||||
boolean: {boolean}
|
||||
integer: {integer}
|
||||
number: {number}
|
||||
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}"""
|
||||
|
||||
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')
|
||||
# - demo_py.py
|
||||
# - demo_sh.sh
|
||||
# - demo_ts.ts
|
||||
- execute_command.sh
|
||||
# - execute_py_code.py
|
||||
# - execute_sql_code.sh
|
||||
@@ -61,6 +62,7 @@ visible_tools: # Which tools are visible to be compiled (and a
|
||||
# - fs_write.sh
|
||||
- get_current_time.sh
|
||||
# - get_current_weather.py
|
||||
# - get_current_weather.ts
|
||||
- get_current_weather.sh
|
||||
- query_jira_issues.sh
|
||||
# - search_arxiv.sh
|
||||
|
||||
+62
-9
@@ -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)
|
||||
- [Python-Based Agent Tools](#python-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)
|
||||
- [6. Todo System & Auto-Continuation](#6-todo-system--auto-continuation)
|
||||
- [7. Sub-Agent Spawning System](#7-sub-agent-spawning-system)
|
||||
@@ -62,10 +63,12 @@ Agent configurations often have the following directory structure:
|
||||
├── tools.sh
|
||||
or
|
||||
├── tools.py
|
||||
or
|
||||
├── tools.ts
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
@@ -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
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
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**
|
||||
`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
|
||||
`agent/my-agent/tools.py` files, refer to the [Building Tools for Agents](#4-building-tools-for-agents) section below.
|
||||
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`, or `agent/my-agent/tools.ts` files, refer to the [Building Tools for Agents](#4-building-tools-for-agents) section below.
|
||||
|
||||
#### Variables
|
||||
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*.
|
||||
|
||||
### Limitations
|
||||
You can only utilize either a bash-based `<loki-config-dir>/agents/my-agent/tools.sh` or 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,
|
||||
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`, 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
|
||||
`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.
|
||||
|
||||
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
|
||||
[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
|
||||
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.
|
||||
|
||||
@@ -10,6 +10,8 @@ into your Loki setup. This document provides a guide on how to create and use cu
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Custom Bash-Based Tools](#custom-bash-based-tools)
|
||||
- [Custom Python-Based Tools](#custom-python-based-tools)
|
||||
- [Custom TypeScript-Based Tools](#custom-typescript-based-tools)
|
||||
- [Custom Runtime](#custom-runtime)
|
||||
<!--toc:end-->
|
||||
|
||||
---
|
||||
@@ -19,9 +21,10 @@ Loki supports custom tools written in the following programming languages:
|
||||
|
||||
* Python
|
||||
* Bash
|
||||
* TypeScript
|
||||
|
||||
## 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
|
||||
your `functions` directory:
|
||||
|
||||
@@ -81,6 +84,7 @@ Loki and demonstrates how to create a Python-based tool:
|
||||
import os
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
|
||||
def run(
|
||||
string: str,
|
||||
string_enum: Literal["foo", "bar"],
|
||||
@@ -89,26 +93,38 @@ def run(
|
||||
number: float,
|
||||
array: List[str],
|
||||
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,
|
||||
):
|
||||
"""Demonstrates how to create a tool using Python and how to use comments.
|
||||
"""Demonstrates all supported Python parameter types and variations.
|
||||
Args:
|
||||
string: Define a required string property
|
||||
string_enum: Define a required string property with enum
|
||||
boolean: Define a required boolean property
|
||||
integer: Define a required integer property
|
||||
number: Define a required number property
|
||||
array: Define a required string array property
|
||||
string_optional: Define an optional string property
|
||||
array_optional: Define an optional string array property
|
||||
string: A required string property
|
||||
string_enum: A required string property constrained to specific values
|
||||
boolean: A required boolean property
|
||||
integer: A required integer property
|
||||
number: A required number (float) property
|
||||
array: A required string array property
|
||||
string_optional: An optional string property (Optional[str] with None default)
|
||||
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}
|
||||
string_enum: {string_enum}
|
||||
string_optional: {string_optional}
|
||||
boolean: {boolean}
|
||||
integer: {integer}
|
||||
number: {number}
|
||||
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}"""
|
||||
|
||||
for key, value in os.environ.items():
|
||||
@@ -117,3 +133,150 @@ array_optional: {array_optional}"""
|
||||
|
||||
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_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_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. | 🔴 |
|
||||
@@ -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_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.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. | 🟢 |
|
||||
| [`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. | 🔴 |
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
# Sisyphus in LangChain/LangGraph
|
||||
|
||||
A faithful recreation of [Loki's Sisyphus agent](../../assets/agents/sisyphus/) using [LangGraph](https://docs.langchain.com/langgraph/) — LangChain's framework for stateful, multi-agent workflows.
|
||||
|
||||
This project exists to help you understand LangChain/LangGraph by mapping every concept to its Loki equivalent.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SUPERVISOR NODE │
|
||||
│ Intent classification → Routing decision → Command(goto=) │
|
||||
│ │
|
||||
│ Loki equivalent: sisyphus/config.yaml │
|
||||
│ (agent__spawn → Command, agent__collect → graph edge) │
|
||||
└──────────┬──────────────┬──────────────┬────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||
│ EXPLORE │ │ ORACLE │ │ CODER │
|
||||
│ (research) │ │ (advise) │ │ (build) │
|
||||
│ │ │ │ │ │
|
||||
│ read-only │ │ read-only │ │ read+write │
|
||||
│ tools │ │ tools │ │ tools │
|
||||
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
|
||||
│ │ │
|
||||
└──────────────┼──────────────┘
|
||||
│
|
||||
back to supervisor
|
||||
```
|
||||
|
||||
## Concept Map: Loki → LangGraph
|
||||
|
||||
This is the key reference. Every row maps a Loki concept to its LangGraph equivalent.
|
||||
|
||||
### Core Architecture
|
||||
|
||||
| Loki Concept | LangGraph Equivalent | Where in Code |
|
||||
|---|---|---|
|
||||
| Agent config (config.yaml) | Node function + system prompt | `agents/explore.py`, etc. |
|
||||
| Agent instructions | System prompt string | `EXPLORE_SYSTEM_PROMPT`, etc. |
|
||||
| Agent tools (tools.sh) | `@tool`-decorated Python functions | `tools/filesystem.py`, `tools/project.py` |
|
||||
| Agent session (chat loop) | Graph state + message list | `state.py` → `SisyphusState.messages` |
|
||||
| `agent__spawn --agent X` | `Command(goto="X")` | `agents/supervisor.py` |
|
||||
| `agent__collect --id` | Graph edge (implicit — workers return to supervisor) | `graph.py` → `add_edge("explore", "supervisor")` |
|
||||
| `agent__check` (non-blocking) | Not needed (graph handles scheduling) | — |
|
||||
| `agent__cancel` | Not needed (graph handles lifecycle) | — |
|
||||
| `can_spawn_agents: true` | Node has routing logic (supervisor) | `agents/supervisor.py` |
|
||||
| `max_concurrent_agents: 4` | `Send()` API for parallel fan-out | See [Parallel Execution](#parallel-execution) |
|
||||
| `max_agent_depth: 3` | `recursion_limit` in config | `cli.py` → `recursion_limit: 50` |
|
||||
| `summarization_threshold` | Manual truncation in supervisor | `supervisor.py` → `_summarize_outputs()` |
|
||||
|
||||
### Tool System
|
||||
|
||||
| Loki Concept | LangGraph Equivalent | Notes |
|
||||
|---|---|---|
|
||||
| `tools.sh` with `@cmd` annotations | `@tool` decorator | Loki compiles bash annotations to JSON schema; LangChain generates schema from the Python function signature + docstring |
|
||||
| `@option --pattern!` (required arg) | Function parameter without default | `def search_content(pattern: str)` |
|
||||
| `@option --lines` (optional arg) | Parameter with default | `def read_file(path: str, limit: int = 200)` |
|
||||
| `@env LLM_OUTPUT=/dev/stdout` | Return value | LangChain tools return strings; Loki tools write to `$LLM_OUTPUT` |
|
||||
| `@describe` | Docstring | The tool's docstring becomes the description the LLM sees |
|
||||
| Global tools (`fs_read.sh`, etc.) | Shared tool imports | Both agents import from `tools/filesystem.py` |
|
||||
| Agent-specific tools | Per-node tool binding | `llm.bind_tools(EXPLORE_TOOLS)` vs `llm.bind_tools(CODER_TOOLS)` |
|
||||
| `.shared/utils.sh` | `tools/project.py` | Shared project detection utilities |
|
||||
| `detect_project()` heuristic | `detect_project()` in Python | Same logic: check Cargo.toml → go.mod → package.json → etc. |
|
||||
| LLM fallback for unknown projects | (omitted) | The agents themselves can reason about unknown project types |
|
||||
|
||||
### State & Memory
|
||||
|
||||
| Loki Concept | LangGraph Equivalent | Notes |
|
||||
|---|---|---|
|
||||
| Agent session (conversation history) | `SisyphusState.messages` | `Annotated[list, add_messages]` — the reducer appends instead of replacing |
|
||||
| `agent_session: temp` | `MemorySaver` checkpointer | Loki's temp sessions are ephemeral; MemorySaver is in-memory (lost on restart) |
|
||||
| Per-agent isolation | Per-node system prompt + tools | In Loki agents have separate sessions; in LangGraph they share messages but have different system prompts |
|
||||
| `{{project_dir}}` variable | `SisyphusState.project_dir` | Loki interpolates variables into prompts; LangGraph stores them in state |
|
||||
| `{{__tools__}}` injection | `llm.bind_tools()` | Loki injects tool descriptions into the prompt; LangChain attaches them to the API call |
|
||||
|
||||
### Orchestration
|
||||
|
||||
| Loki Concept | LangGraph Equivalent | Notes |
|
||||
|---|---|---|
|
||||
| Intent classification table | `RoutingDecision` structured output | Loki does this in free text; LangGraph forces typed JSON |
|
||||
| Oracle triggers ("How should I...") | Supervisor prompt + structured output | Same trigger phrases, enforced via system prompt |
|
||||
| Coder delegation format | Supervisor builds HumanMessage | The structured prompt (Goal/Reference Files/Conventions/Constraints) |
|
||||
| `agent__spawn` (parallel) | `Send()` API | Dynamic fan-out to multiple nodes |
|
||||
| Todo system (`todo__init`, etc.) | `SisyphusState.todos` | State field with a merge reducer |
|
||||
| `auto_continue: true` | Supervisor loop (iteration counter) | Supervisor re-routes until FINISH or max iterations |
|
||||
| `max_auto_continues: 25` | `MAX_ITERATIONS = 15` | Safety valve to prevent infinite loops |
|
||||
| `user__ask` / `user__confirm` | `interrupt()` API | Pauses graph, surfaces question to caller, resumes with answer |
|
||||
| Escalation (child → parent → user) | `interrupt()` in any node | Any node can pause; the caller handles the interaction |
|
||||
|
||||
### Execution Model
|
||||
|
||||
| Loki Concept | LangGraph Equivalent | Notes |
|
||||
|---|---|---|
|
||||
| `loki --agent sisyphus` | `python -m sisyphus_langchain.cli` | CLI entry point |
|
||||
| REPL mode | `cli.py` → `repl()` | Interactive loop with thread persistence |
|
||||
| One-shot mode | `cli.py` → `run_query()` | Single query, print result, exit |
|
||||
| Streaming output | `graph.stream()` | LangGraph supports per-node streaming |
|
||||
| `inject_spawn_instructions` | (always on) | System prompts are always included |
|
||||
| `inject_todo_instructions` | (always on) | Todo instructions could be added to prompts |
|
||||
|
||||
## How the Execution Flow Works
|
||||
|
||||
### 1. User sends a message
|
||||
|
||||
```python
|
||||
graph.invoke({"messages": [HumanMessage("Add a health check endpoint")]})
|
||||
```
|
||||
|
||||
### 2. Supervisor classifies intent
|
||||
|
||||
The supervisor LLM reads the message and produces a `RoutingDecision`:
|
||||
```json
|
||||
{
|
||||
"intent": "implementation",
|
||||
"next_agent": "explore",
|
||||
"delegation_notes": "Find existing API endpoint patterns, route structure, and health check conventions"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Supervisor routes via Command
|
||||
|
||||
```python
|
||||
return Command(goto="explore", update={"intent": "implementation", "iteration_count": 1})
|
||||
```
|
||||
|
||||
### 4. Explore agent runs
|
||||
|
||||
- Receives the full message history (including the user's request)
|
||||
- Calls read-only tools (search_content, search_files, read_file)
|
||||
- Returns findings in messages
|
||||
|
||||
### 5. Control returns to supervisor
|
||||
|
||||
The graph edge `explore → supervisor` fires automatically.
|
||||
|
||||
### 6. Supervisor reviews and routes again
|
||||
|
||||
Now it has explore's findings. It routes to coder with context:
|
||||
```json
|
||||
{
|
||||
"intent": "implementation",
|
||||
"next_agent": "coder",
|
||||
"delegation_notes": "Implement health check endpoint following patterns found in src/routes/"
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Coder implements
|
||||
|
||||
- Reads explore's findings from the message history
|
||||
- Writes files via `write_file` tool
|
||||
- Runs `verify_build` to check compilation
|
||||
|
||||
### 8. Supervisor verifies and finishes
|
||||
|
||||
```json
|
||||
{
|
||||
"intent": "implementation",
|
||||
"next_agent": "FINISH",
|
||||
"delegation_notes": "Added /health endpoint in src/routes/health.py. Build passes."
|
||||
}
|
||||
```
|
||||
|
||||
## Key Differences from Loki
|
||||
|
||||
### What LangGraph does better
|
||||
|
||||
1. **Declarative graph** — The topology is visible and debuggable. Loki's orchestration is emergent from the LLM's tool calls.
|
||||
2. **Typed state** — `SisyphusState` is a TypedDict with reducers. Loki's state is implicit in the conversation.
|
||||
3. **Checkpointing** — Built-in persistence. Loki manages sessions manually.
|
||||
4. **Time-travel debugging** — Inspect any checkpoint. Loki has no equivalent.
|
||||
5. **Structured routing** — `RoutingDecision` forces valid JSON. Loki relies on the LLM calling the right tool.
|
||||
|
||||
### What Loki does better
|
||||
|
||||
1. **True parallelism** — `agent__spawn` runs multiple agents concurrently in separate threads. This LangGraph implementation is sequential (see [Parallel Execution](#parallel-execution) for how to add it).
|
||||
2. **Agent isolation** — Each Loki agent has its own session, tools, and config. LangGraph nodes share state.
|
||||
3. **Teammate messaging** — Loki agents can send messages to siblings. LangGraph nodes communicate only through shared state.
|
||||
4. **Dynamic tool compilation** — Loki compiles bash/python/typescript tools at startup. LangChain tools are statically defined.
|
||||
5. **Escalation protocol** — Loki's child-to-parent escalation is sophisticated. LangGraph's `interrupt()` is simpler but less structured.
|
||||
6. **Task queues with dependencies** — Loki's `agent__task_create` supports dependency DAGs. LangGraph's routing is simpler (hub-and-spoke).
|
||||
|
||||
## Running It
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# Python 3.11+
|
||||
python --version
|
||||
|
||||
# Set your API key
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
```
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
cd examples/langchain-sisyphus
|
||||
|
||||
# With pip
|
||||
pip install -e .
|
||||
|
||||
# Or with uv (recommended)
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Interactive REPL (like `loki --agent sisyphus`)
|
||||
sisyphus
|
||||
|
||||
# One-shot query
|
||||
sisyphus "Find all TODO comments in the codebase"
|
||||
|
||||
# With custom models (cost optimization)
|
||||
sisyphus --explore-model gpt-4o-mini --coder-model gpt-4o "Add input validation to the API"
|
||||
|
||||
# Programmatic usage
|
||||
python -c "
|
||||
from sisyphus_langchain import build_graph
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
graph = build_graph()
|
||||
result = graph.invoke({
|
||||
'messages': [HumanMessage('What patterns does this codebase use?')],
|
||||
'intent': 'ambiguous',
|
||||
'next_agent': '',
|
||||
'iteration_count': 0,
|
||||
'todos': [],
|
||||
'agent_outputs': {},
|
||||
'final_output': '',
|
||||
'project_dir': '.',
|
||||
}, config={'configurable': {'thread_id': 'demo'}, 'recursion_limit': 50})
|
||||
print(result['final_output'])
|
||||
"
|
||||
```
|
||||
|
||||
### Using Anthropic Models
|
||||
|
||||
Replace `ChatOpenAI` with `ChatAnthropic` in the agent factories:
|
||||
|
||||
```python
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
# In agents/oracle.py:
|
||||
llm = ChatAnthropic(model="claude-sonnet-4-20250514", temperature=0.2).bind_tools(ORACLE_TOOLS)
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Option 1: Standalone Script (Simplest)
|
||||
|
||||
Just run the CLI directly. No infrastructure needed.
|
||||
|
||||
```bash
|
||||
sisyphus "Add a health check endpoint"
|
||||
```
|
||||
|
||||
### Option 2: FastAPI Server
|
||||
|
||||
```python
|
||||
# server.py
|
||||
from fastapi import FastAPI
|
||||
from langserve import add_routes
|
||||
from sisyphus_langchain import build_graph
|
||||
|
||||
app = FastAPI(title="Sisyphus API")
|
||||
graph = build_graph()
|
||||
add_routes(app, graph, path="/agent")
|
||||
|
||||
# Run: uvicorn server:app --host 0.0.0.0 --port 8000
|
||||
# Call: POST http://localhost:8000/agent/invoke
|
||||
```
|
||||
|
||||
### Option 3: LangGraph Platform (Production)
|
||||
|
||||
Create a `langgraph.json` at the project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"graphs": {
|
||||
"sisyphus": "./sisyphus_langchain/graph.py:build_graph"
|
||||
},
|
||||
"dependencies": ["./sisyphus_langchain"],
|
||||
"env": ".env"
|
||||
}
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
```bash
|
||||
pip install langgraph-cli
|
||||
langgraph deploy
|
||||
```
|
||||
|
||||
This gives you:
|
||||
- Durable checkpointing (PostgreSQL)
|
||||
- Background runs
|
||||
- Streaming API
|
||||
- Zero-downtime deployments
|
||||
- Built-in observability
|
||||
|
||||
### Option 4: Docker
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN pip install -e .
|
||||
CMD ["sisyphus"]
|
||||
```
|
||||
|
||||
```bash
|
||||
docker build -t sisyphus .
|
||||
docker run -it -e OPENAI_API_KEY=$OPENAI_API_KEY sisyphus
|
||||
```
|
||||
|
||||
## Parallel Execution
|
||||
|
||||
This implementation routes sequentially for simplicity. To add Loki-style parallel agent execution, use LangGraph's `Send()` API:
|
||||
|
||||
```python
|
||||
from langgraph.types import Send
|
||||
|
||||
def supervisor_node(state):
|
||||
# Fan out to multiple explore agents in parallel
|
||||
# (like Loki's agent__spawn called multiple times)
|
||||
return [
|
||||
Send("explore", {
|
||||
**state,
|
||||
"messages": state["messages"] + [
|
||||
HumanMessage("Find existing API endpoint patterns")
|
||||
],
|
||||
}),
|
||||
Send("explore", {
|
||||
**state,
|
||||
"messages": state["messages"] + [
|
||||
HumanMessage("Find data models and database patterns")
|
||||
],
|
||||
}),
|
||||
]
|
||||
```
|
||||
|
||||
This is equivalent to Loki's pattern of spawning multiple explore agents:
|
||||
```
|
||||
agent__spawn --agent explore --prompt "Find API patterns"
|
||||
agent__spawn --agent explore --prompt "Find database patterns"
|
||||
agent__collect --id <id1>
|
||||
agent__collect --id <id2>
|
||||
```
|
||||
|
||||
## Adding Human-in-the-Loop
|
||||
|
||||
To replicate Loki's `user__ask` / `user__confirm` tools, use LangGraph's `interrupt()`:
|
||||
|
||||
```python
|
||||
from langgraph.types import interrupt
|
||||
|
||||
def supervisor_node(state):
|
||||
# Pause and ask the user (like Loki's user__ask)
|
||||
answer = interrupt({
|
||||
"question": "How should we structure the authentication?",
|
||||
"options": [
|
||||
"JWT with httpOnly cookies (Recommended)",
|
||||
"Session-based with Redis",
|
||||
"OAuth2 with external provider",
|
||||
],
|
||||
})
|
||||
# `answer` contains the user's selection when the graph resumes
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
examples/langchain-sisyphus/
|
||||
├── pyproject.toml # Dependencies & build config
|
||||
├── README.md # This file
|
||||
└── sisyphus_langchain/
|
||||
├── __init__.py # Package entry point
|
||||
├── cli.py # CLI (REPL + one-shot mode)
|
||||
├── graph.py # Graph assembly (wires nodes + edges)
|
||||
├── state.py # Shared state schema (TypedDict)
|
||||
├── agents/
|
||||
│ ├── __init__.py
|
||||
│ ├── supervisor.py # Sisyphus orchestrator (intent → routing)
|
||||
│ ├── explore.py # Read-only codebase researcher
|
||||
│ ├── oracle.py # Architecture/debugging advisor
|
||||
│ └── coder.py # Implementation worker
|
||||
└── tools/
|
||||
├── __init__.py
|
||||
├── filesystem.py # File read/write/search/glob tools
|
||||
└── project.py # Project detection, build, test tools
|
||||
```
|
||||
|
||||
### File-to-Loki Mapping
|
||||
|
||||
| This Project | Loki Equivalent |
|
||||
|---|---|
|
||||
| `state.py` | Session context + todo state (implicit in Loki) |
|
||||
| `graph.py` | `src/supervisor/mod.rs` (runtime orchestration) |
|
||||
| `cli.py` | `src/main.rs` (CLI entry point) |
|
||||
| `agents/supervisor.py` | `assets/agents/sisyphus/config.yaml` |
|
||||
| `agents/explore.py` | `assets/agents/explore/config.yaml` + `tools.sh` |
|
||||
| `agents/oracle.py` | `assets/agents/oracle/config.yaml` + `tools.sh` |
|
||||
| `agents/coder.py` | `assets/agents/coder/config.yaml` + `tools.sh` |
|
||||
| `tools/filesystem.py` | `assets/functions/tools/fs_*.sh` |
|
||||
| `tools/project.py` | `assets/agents/.shared/utils.sh` + `sisyphus/tools.sh` |
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [LangGraph Documentation](https://docs.langchain.com/langgraph/)
|
||||
- [LangGraph Multi-Agent Tutorial](https://docs.langchain.com/langgraph/how-tos/multi-agent-systems)
|
||||
- [Loki Agents Documentation](../../docs/AGENTS.md)
|
||||
- [Loki Sisyphus README](../../assets/agents/sisyphus/README.md)
|
||||
- [LangGraph Supervisor Library](https://github.com/langchain-ai/langgraph-supervisor-py)
|
||||
@@ -0,0 +1,29 @@
|
||||
[project]
|
||||
name = "sisyphus-langchain"
|
||||
version = "0.1.0"
|
||||
description = "Loki's Sisyphus multi-agent orchestrator recreated in LangChain/LangGraph"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"langgraph>=0.3.0",
|
||||
"langchain>=0.3.0",
|
||||
"langchain-openai>=0.3.0",
|
||||
"langchain-anthropic>=0.3.0",
|
||||
"langchain-core>=0.3.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"ruff>=0.8.0",
|
||||
]
|
||||
server = [
|
||||
"langgraph-api>=0.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
sisyphus = "sisyphus_langchain.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Sisyphus multi-agent orchestrator — a LangGraph recreation of Loki's Sisyphus agent."""
|
||||
|
||||
from sisyphus_langchain.graph import build_graph
|
||||
|
||||
__all__ = ["build_graph"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Agent node definitions for the Sisyphus orchestrator."""
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Coder agent node — the implementation worker.
|
||||
|
||||
Loki equivalent: assets/agents/coder/config.yaml + tools.sh
|
||||
|
||||
In Loki, the coder is the ONLY agent that modifies files. It:
|
||||
- Receives a structured prompt from sisyphus with code patterns to follow
|
||||
- Writes files via the write_file tool (never pastes code in chat)
|
||||
- Verifies builds after every change
|
||||
- Signals CODER_COMPLETE or CODER_FAILED
|
||||
|
||||
In LangGraph, coder is a node with write-capable tools (read_file, write_file,
|
||||
search_content, execute_command, verify_build). The supervisor formats a
|
||||
structured delegation prompt (Goal / Reference Files / Code Patterns /
|
||||
Conventions / Constraints) and routes to this node.
|
||||
|
||||
Key Loki→LangGraph mapping:
|
||||
- Loki's "Coder Delegation Format" → the supervisor builds this as a
|
||||
HumanMessage before routing to the coder node.
|
||||
- Loki's auto_continue (up to 15) → the supervisor can re-route to coder
|
||||
if verification fails, up to iteration_count limits.
|
||||
- Loki's todo system for multi-file changes → the coder updates
|
||||
state["todos"] as it completes each file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from sisyphus_langchain.state import SisyphusState
|
||||
from sisyphus_langchain.tools.filesystem import (
|
||||
read_file,
|
||||
search_content,
|
||||
search_files,
|
||||
write_file,
|
||||
)
|
||||
from sisyphus_langchain.tools.project import (
|
||||
execute_command,
|
||||
run_tests,
|
||||
verify_build,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System prompt — faithfully mirrors coder/config.yaml
|
||||
# ---------------------------------------------------------------------------
|
||||
CODER_SYSTEM_PROMPT = """\
|
||||
You are a senior engineer. You write code that works on the first try.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Given an implementation task:
|
||||
1. Check for context provided in the conversation (patterns, conventions, reference files).
|
||||
2. Fill gaps only — read files NOT already covered in context.
|
||||
3. Write the code using the write_file tool (NEVER output code in chat).
|
||||
4. Verify it compiles/builds using verify_build.
|
||||
5. Provide a summary of what you implemented.
|
||||
|
||||
## Using Provided Context (IMPORTANT)
|
||||
|
||||
Your prompt often contains prior findings from the explore agent: file paths,
|
||||
code patterns, and conventions.
|
||||
|
||||
**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 referenced files ONLY IF you need more detail (full signatures, imports).
|
||||
4. If context includes a "Conventions" section, follow it exactly.
|
||||
|
||||
**If context is NOT provided or is too vague:**
|
||||
Fall back to self-exploration: search for similar files, read 1-2 examples,
|
||||
match their style.
|
||||
|
||||
## Writing Code
|
||||
|
||||
CRITICAL: Write code using the write_file tool. NEVER paste code in chat.
|
||||
|
||||
## Pattern Matching
|
||||
|
||||
Before writing ANY file:
|
||||
1. Find a similar existing file.
|
||||
2. Match its style: imports, naming, structure.
|
||||
3. Follow the same patterns exactly.
|
||||
|
||||
## Verification
|
||||
|
||||
After writing files:
|
||||
1. Run verify_build to check compilation.
|
||||
2. If it fails, fix the error (minimal change).
|
||||
3. Don't move on until build passes.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Write code via tools — never output code to chat.
|
||||
2. Follow patterns — read existing files first.
|
||||
3. Verify builds — don't finish without checking.
|
||||
4. Minimal fixes — if build fails, fix precisely.
|
||||
5. No refactoring — only implement what's asked.
|
||||
"""
|
||||
|
||||
# Full tool set — coder gets write access and command execution
|
||||
CODER_TOOLS = [
|
||||
read_file,
|
||||
write_file,
|
||||
search_content,
|
||||
search_files,
|
||||
execute_command,
|
||||
verify_build,
|
||||
run_tests,
|
||||
]
|
||||
|
||||
|
||||
def create_coder_node(model_name: str = "gpt-4o", temperature: float = 0.1):
|
||||
"""
|
||||
Factory that returns a coder node function.
|
||||
|
||||
Coder needs a capable model because it writes production code. In Loki,
|
||||
coder uses the same model as the parent by default.
|
||||
|
||||
Args:
|
||||
model_name: Model identifier.
|
||||
temperature: LLM temperature (Loki coder uses 0.1 for consistency).
|
||||
"""
|
||||
llm = ChatOpenAI(model=model_name, temperature=temperature).bind_tools(CODER_TOOLS)
|
||||
|
||||
def coder_node(state: SisyphusState) -> dict:
|
||||
"""
|
||||
LangGraph node: run the coder agent.
|
||||
|
||||
Reads conversation history (including the supervisor's structured
|
||||
delegation prompt), invokes the LLM with write-capable tools,
|
||||
and returns the result.
|
||||
"""
|
||||
response = llm.invoke(
|
||||
[SystemMessage(content=CODER_SYSTEM_PROMPT)] + state["messages"]
|
||||
)
|
||||
return {
|
||||
"messages": [response],
|
||||
"agent_outputs": {
|
||||
**state.get("agent_outputs", {}),
|
||||
"coder": response.content,
|
||||
},
|
||||
}
|
||||
|
||||
return coder_node
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Explore agent node — the read-only codebase researcher.
|
||||
|
||||
Loki equivalent: assets/agents/explore/config.yaml + tools.sh
|
||||
|
||||
In Loki, the explore agent is spawned via `agent__spawn --agent explore --prompt "..."`
|
||||
and runs as an isolated subprocess with its own session. It ends with
|
||||
"EXPLORE_COMPLETE" so the parent knows it's finished.
|
||||
|
||||
In LangGraph, the explore agent is a *node* in the graph. The supervisor routes
|
||||
to it via `Command(goto="explore")`. It reads the latest message (the supervisor's
|
||||
delegation prompt), calls the LLM with read-only tools, and writes its findings
|
||||
back to the shared message list. The graph edge then returns control to the
|
||||
supervisor.
|
||||
|
||||
Key differences from Loki:
|
||||
- No isolated session — shares the graph's message list (but has its own
|
||||
system prompt and tool set, just like Loki's per-agent config).
|
||||
- No "EXPLORE_COMPLETE" sentinel — the graph edge handles control flow.
|
||||
- No output summarization — LangGraph's state handles context management.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from sisyphus_langchain.state import SisyphusState
|
||||
from sisyphus_langchain.tools.filesystem import (
|
||||
list_directory,
|
||||
read_file,
|
||||
search_content,
|
||||
search_files,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System prompt — faithfully mirrors explore/config.yaml
|
||||
# ---------------------------------------------------------------------------
|
||||
EXPLORE_SYSTEM_PROMPT = """\
|
||||
You are a codebase explorer. Your job: Search, find, report. Nothing else.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Given a search task, you:
|
||||
1. Search for relevant files and patterns
|
||||
2. Read key files to understand structure
|
||||
3. Report findings concisely
|
||||
|
||||
## Strategy
|
||||
|
||||
1. **Find first, read second** — Never read a file without knowing why.
|
||||
2. **Use search_content to locate** — find exactly where things are defined.
|
||||
3. **Use search_files to discover** — find files by name pattern.
|
||||
4. **Read targeted sections** — use offset and limit to read only relevant lines.
|
||||
5. **Never read entire large files** — if a file is 500+ lines, read the relevant section only.
|
||||
|
||||
## Output Format
|
||||
|
||||
Always end your response with a structured findings summary:
|
||||
|
||||
FINDINGS:
|
||||
- [Key finding 1]
|
||||
- [Key finding 2]
|
||||
- Relevant files: [list of paths]
|
||||
|
||||
## Rules
|
||||
|
||||
1. Be fast — don't read every file, read representative ones.
|
||||
2. Be focused — answer the specific question asked.
|
||||
3. Be concise — report findings, not your process.
|
||||
4. Never modify files — you are read-only.
|
||||
5. Limit reads — max 5 file reads per exploration.
|
||||
"""
|
||||
|
||||
# Read-only tools — mirrors explore's tool set (no write_file, no execute_command)
|
||||
EXPLORE_TOOLS = [read_file, search_content, search_files, list_directory]
|
||||
|
||||
|
||||
def create_explore_node(model_name: str = "gpt-4o-mini", temperature: float = 0.1):
|
||||
"""
|
||||
Factory that returns an explore node function bound to a specific model.
|
||||
|
||||
In Loki, the model is set per-agent in config.yaml. Here we parameterize it
|
||||
so you can use a cheap model for exploration (cost optimization).
|
||||
|
||||
Args:
|
||||
model_name: OpenAI model identifier.
|
||||
temperature: LLM temperature (Loki explore uses 0.1).
|
||||
"""
|
||||
llm = ChatOpenAI(model=model_name, temperature=temperature).bind_tools(EXPLORE_TOOLS)
|
||||
|
||||
def explore_node(state: SisyphusState) -> dict:
|
||||
"""
|
||||
LangGraph node: run the explore agent.
|
||||
|
||||
Reads the conversation history, applies the explore system prompt,
|
||||
invokes the LLM with read-only tools, and returns the response.
|
||||
"""
|
||||
response = llm.invoke(
|
||||
[SystemMessage(content=EXPLORE_SYSTEM_PROMPT)] + state["messages"]
|
||||
)
|
||||
return {
|
||||
"messages": [response],
|
||||
"agent_outputs": {
|
||||
**state.get("agent_outputs", {}),
|
||||
"explore": response.content,
|
||||
},
|
||||
}
|
||||
|
||||
return explore_node
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Oracle agent node — the high-IQ architecture and debugging advisor.
|
||||
|
||||
Loki equivalent: assets/agents/oracle/config.yaml + tools.sh
|
||||
|
||||
In Loki, the oracle is a READ-ONLY advisor spawned for:
|
||||
- Architecture decisions and multi-system tradeoffs
|
||||
- Complex debugging (after 2+ failed fix attempts)
|
||||
- Code/design review
|
||||
- Risk assessment
|
||||
|
||||
It uses temperature 0.2 (slightly higher than explore/coder for more creative
|
||||
reasoning) and ends with "ORACLE_COMPLETE".
|
||||
|
||||
In LangGraph, oracle is a node that receives the full message history, reasons
|
||||
about the problem, and writes structured advice back. It has read-only tools
|
||||
only — it never modifies files.
|
||||
|
||||
Key Loki→LangGraph mapping:
|
||||
- Loki oracle triggers (the "MUST spawn oracle when..." rules in sisyphus)
|
||||
become routing conditions in the supervisor node.
|
||||
- Oracle's structured output format (Analysis/Recommendation/Reasoning/Risks)
|
||||
is enforced via the system prompt, same as in Loki.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from sisyphus_langchain.state import SisyphusState
|
||||
from sisyphus_langchain.tools.filesystem import (
|
||||
list_directory,
|
||||
read_file,
|
||||
search_content,
|
||||
search_files,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System prompt — faithfully mirrors oracle/config.yaml
|
||||
# ---------------------------------------------------------------------------
|
||||
ORACLE_SYSTEM_PROMPT = """\
|
||||
You are Oracle — a senior architect and debugger consulted for complex decisions.
|
||||
|
||||
## Your Role
|
||||
|
||||
You are READ-ONLY. You analyze, advise, and recommend. You do NOT implement.
|
||||
|
||||
## When You're Consulted
|
||||
|
||||
1. **Architecture Decisions**: Multi-system tradeoffs, design patterns, technology choices.
|
||||
2. **Complex Debugging**: After 2+ failed fix attempts, deep analysis needed.
|
||||
3. **Code Review**: Evaluating proposed designs or implementations.
|
||||
4. **Risk Assessment**: Security, performance, or reliability concerns.
|
||||
|
||||
## Your Process
|
||||
|
||||
1. **Understand**: Read relevant code, understand the full context.
|
||||
2. **Analyze**: Consider multiple angles and tradeoffs.
|
||||
3. **Recommend**: Provide clear, actionable advice.
|
||||
4. **Justify**: Explain your reasoning.
|
||||
|
||||
## Output Format
|
||||
|
||||
Structure your response as:
|
||||
|
||||
## Analysis
|
||||
[Your understanding of the situation]
|
||||
|
||||
## Recommendation
|
||||
[Clear, specific advice]
|
||||
|
||||
## Reasoning
|
||||
[Why this is the right approach]
|
||||
|
||||
## Risks/Considerations
|
||||
[What to watch out for]
|
||||
|
||||
## Rules
|
||||
|
||||
1. Never modify files — you advise, others implement.
|
||||
2. Be thorough — read all relevant context before advising.
|
||||
3. Be specific — general advice isn't helpful.
|
||||
4. Consider tradeoffs — there are rarely perfect solutions.
|
||||
5. Stay focused — answer the specific question asked.
|
||||
"""
|
||||
|
||||
# Read-only tools — same set as explore (oracle never writes)
|
||||
ORACLE_TOOLS = [read_file, search_content, search_files, list_directory]
|
||||
|
||||
|
||||
def create_oracle_node(model_name: str = "gpt-4o", temperature: float = 0.2):
|
||||
"""
|
||||
Factory that returns an oracle node function.
|
||||
|
||||
Oracle uses a more expensive model than explore because it needs deeper
|
||||
reasoning. In Loki, the model is inherited from the global config unless
|
||||
overridden in oracle/config.yaml.
|
||||
|
||||
Args:
|
||||
model_name: Model identifier (use a strong reasoning model).
|
||||
temperature: LLM temperature (Loki oracle uses 0.2).
|
||||
"""
|
||||
llm = ChatOpenAI(model=model_name, temperature=temperature).bind_tools(ORACLE_TOOLS)
|
||||
|
||||
def oracle_node(state: SisyphusState) -> dict:
|
||||
"""
|
||||
LangGraph node: run the oracle agent.
|
||||
|
||||
Reads conversation history, applies the oracle system prompt,
|
||||
invokes the LLM, and returns structured advice.
|
||||
"""
|
||||
response = llm.invoke(
|
||||
[SystemMessage(content=ORACLE_SYSTEM_PROMPT)] + state["messages"]
|
||||
)
|
||||
return {
|
||||
"messages": [response],
|
||||
"agent_outputs": {
|
||||
**state.get("agent_outputs", {}),
|
||||
"oracle": response.content,
|
||||
},
|
||||
}
|
||||
|
||||
return oracle_node
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Sisyphus supervisor node — the orchestrator that classifies intent and routes.
|
||||
|
||||
Loki equivalent: assets/agents/sisyphus/config.yaml
|
||||
|
||||
This is the brain of the system. In Loki, Sisyphus is the top-level agent that:
|
||||
1. Classifies every incoming request (trivial / exploration / implementation /
|
||||
architecture / ambiguous)
|
||||
2. Routes to the appropriate sub-agent (explore, coder, oracle)
|
||||
3. Manages the todo list for multi-step tasks
|
||||
4. Verifies results and decides when the task is complete
|
||||
|
||||
In LangGraph, the supervisor is a node that returns `Command(goto="agent_name")`
|
||||
to route control. This replaces Loki's `agent__spawn` + `agent__collect` pattern
|
||||
with a declarative graph edge.
|
||||
|
||||
Key Loki→LangGraph mapping:
|
||||
- agent__spawn --agent explore → Command(goto="explore")
|
||||
- agent__spawn --agent coder → Command(goto="coder")
|
||||
- agent__spawn --agent oracle → Command(goto="oracle")
|
||||
- agent__check / agent__collect → (implicit: graph edges return to supervisor)
|
||||
- todo__init / todo__add → state["todos"] updates
|
||||
- user__ask / user__confirm → interrupt() for human-in-the-loop
|
||||
|
||||
Parallel execution note:
|
||||
Loki can spawn multiple explore agents in parallel. In LangGraph, you'd use
|
||||
the Send() API for dynamic fan-out. For simplicity, this implementation uses
|
||||
sequential routing. See the README for how to add parallel fan-out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import Command
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from sisyphus_langchain.state import SisyphusState
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maximum iterations before forcing completion (safety valve)
|
||||
# Mirrors Loki's max_auto_continues: 25
|
||||
# ---------------------------------------------------------------------------
|
||||
MAX_ITERATIONS = 15
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structured output schema for the supervisor's routing decision.
|
||||
#
|
||||
# In Loki, the supervisor is an LLM that produces free-text and calls tools
|
||||
# like agent__spawn. In LangGraph, we use structured output to force the
|
||||
# LLM into a typed routing decision — more reliable than parsing free text.
|
||||
# ---------------------------------------------------------------------------
|
||||
class RoutingDecision(BaseModel):
|
||||
"""The supervisor's decision about what to do next."""
|
||||
|
||||
intent: Literal["trivial", "exploration", "implementation", "architecture", "ambiguous"] = Field(
|
||||
description="Classified intent of the user's request."
|
||||
)
|
||||
next_agent: Literal["explore", "oracle", "coder", "FINISH"] = Field(
|
||||
description=(
|
||||
"Which agent to route to. 'explore' for research/discovery, "
|
||||
"'oracle' for architecture/design/debugging advice, "
|
||||
"'coder' for implementation, 'FINISH' if the task is complete."
|
||||
)
|
||||
)
|
||||
delegation_notes: str = Field(
|
||||
description=(
|
||||
"Brief instructions for the target agent: what to look for (explore), "
|
||||
"what to analyze (oracle), or what to implement (coder). "
|
||||
"For FINISH, summarize what was accomplished."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor system prompt — faithfully mirrors sisyphus/config.yaml
|
||||
# ---------------------------------------------------------------------------
|
||||
SUPERVISOR_SYSTEM_PROMPT = """\
|
||||
You are Sisyphus — an orchestrator that drives coding tasks to completion.
|
||||
|
||||
Your job: Classify → Delegate → Verify → Complete.
|
||||
|
||||
## Intent Classification (BEFORE every action)
|
||||
|
||||
| Type | Signal | Action |
|
||||
|-----------------|-----------------------------------------------------|----------------------|
|
||||
| trivial | Single file, known location, typo fix | Route to FINISH |
|
||||
| exploration | "Find X", "Where is Y", "List all Z" | Route to explore |
|
||||
| implementation | "Add feature", "Fix bug", "Write code" | Route to coder |
|
||||
| architecture | See oracle triggers below | Route to oracle |
|
||||
| ambiguous | Unclear scope, multiple interpretations | Route to FINISH with a clarifying question |
|
||||
|
||||
## Oracle Triggers (MUST route to oracle when you see these)
|
||||
|
||||
Route to oracle ANY time the user asks about:
|
||||
- "How should I..." / "What's the best way to..." — design/approach questions
|
||||
- "Why does X keep..." / "What's wrong with..." — complex debugging
|
||||
- "Should I use X or Y?" — technology or pattern choices
|
||||
- "How should this be structured?" — architecture
|
||||
- "Review this" / "What do you think of..." — code/design review
|
||||
- Tradeoff questions, multi-component questions, vague/open-ended questions
|
||||
|
||||
## Agent Specializations
|
||||
|
||||
| Agent | Use For |
|
||||
|---------|-----------------------------------------------|
|
||||
| explore | Find patterns, understand code, search |
|
||||
| coder | Write/edit files, implement features |
|
||||
| oracle | Architecture decisions, complex debugging |
|
||||
|
||||
## Workflow Patterns
|
||||
|
||||
### Implementation task: explore → coder
|
||||
1. Route to explore to find existing patterns and conventions.
|
||||
2. Review explore findings.
|
||||
3. Route to coder with a structured prompt including the explore findings.
|
||||
4. Verify the coder's output (check for CODER_COMPLETE or CODER_FAILED).
|
||||
|
||||
### Architecture question: explore + oracle
|
||||
1. Route to explore to find relevant code.
|
||||
2. Route to oracle with the explore findings for analysis.
|
||||
|
||||
### Simple question: oracle directly
|
||||
For pure design/architecture questions, route to oracle directly.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Always classify before acting.
|
||||
2. You are a coordinator, not an implementer.
|
||||
3. Route to oracle for ANY design/architecture question.
|
||||
4. When routing to coder, include code patterns from explore findings.
|
||||
5. Route to FINISH when the task is fully addressed.
|
||||
|
||||
## Current State
|
||||
|
||||
Iteration: {iteration_count}/{max_iterations}
|
||||
Previous agent outputs: {agent_outputs}
|
||||
"""
|
||||
|
||||
|
||||
def create_supervisor_node(model_name: str = "gpt-4o", temperature: float = 0.1):
|
||||
"""
|
||||
Factory that returns a supervisor node function.
|
||||
|
||||
The supervisor uses a capable model for accurate routing.
|
||||
|
||||
Args:
|
||||
model_name: Model identifier.
|
||||
temperature: LLM temperature (low for consistent routing).
|
||||
"""
|
||||
llm = ChatOpenAI(model=model_name, temperature=temperature).with_structured_output(
|
||||
RoutingDecision
|
||||
)
|
||||
|
||||
def supervisor_node(
|
||||
state: SisyphusState,
|
||||
) -> Command[Literal["explore", "oracle", "coder", "__end__"]]:
|
||||
"""
|
||||
LangGraph node: the Sisyphus supervisor.
|
||||
|
||||
Classifies the user's intent, decides which agent to route to,
|
||||
and returns a Command that directs graph execution.
|
||||
"""
|
||||
iteration = state.get("iteration_count", 0)
|
||||
|
||||
# Safety valve — prevent infinite loops
|
||||
if iteration >= MAX_ITERATIONS:
|
||||
return Command(
|
||||
goto="__end__",
|
||||
update={
|
||||
"final_output": "Reached maximum iterations. Here's what was accomplished:\n"
|
||||
+ "\n".join(
|
||||
f"- {k}: {v[:200]}" for k, v in state.get("agent_outputs", {}).items()
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Format the system prompt with current state
|
||||
prompt = SUPERVISOR_SYSTEM_PROMPT.format(
|
||||
iteration_count=iteration,
|
||||
max_iterations=MAX_ITERATIONS,
|
||||
agent_outputs=_summarize_outputs(state.get("agent_outputs", {})),
|
||||
)
|
||||
|
||||
# Invoke the LLM to get a structured routing decision
|
||||
decision: RoutingDecision = llm.invoke(
|
||||
[SystemMessage(content=prompt)] + state["messages"]
|
||||
)
|
||||
|
||||
# Route to FINISH
|
||||
if decision.next_agent == "FINISH":
|
||||
return Command(
|
||||
goto="__end__",
|
||||
update={
|
||||
"intent": decision.intent,
|
||||
"next_agent": "FINISH",
|
||||
"final_output": decision.delegation_notes,
|
||||
},
|
||||
)
|
||||
|
||||
# Route to a worker agent
|
||||
return Command(
|
||||
goto=decision.next_agent,
|
||||
update={
|
||||
"intent": decision.intent,
|
||||
"next_agent": decision.next_agent,
|
||||
"iteration_count": iteration + 1,
|
||||
},
|
||||
)
|
||||
|
||||
return supervisor_node
|
||||
|
||||
|
||||
def _summarize_outputs(outputs: dict[str, str]) -> str:
|
||||
"""Summarize agent outputs for the supervisor's context window."""
|
||||
if not outputs:
|
||||
return "(none yet)"
|
||||
parts = []
|
||||
for agent, output in outputs.items():
|
||||
# Truncate long outputs to keep supervisor context manageable
|
||||
# This mirrors Loki's summarization_threshold behavior
|
||||
if len(output) > 2000:
|
||||
output = output[:2000] + "... (truncated)"
|
||||
parts.append(f"[{agent}]: {output}")
|
||||
return "\n\n".join(parts)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
CLI entry point for the Sisyphus LangChain agent.
|
||||
|
||||
This mirrors Loki's `loki --agent sisyphus` entry point.
|
||||
|
||||
In Loki:
|
||||
loki --agent sisyphus
|
||||
# Starts a REPL with the sisyphus agent loaded
|
||||
|
||||
In this LangChain version:
|
||||
python -m sisyphus_langchain.cli
|
||||
# or: sisyphus (if installed via pip)
|
||||
|
||||
Usage:
|
||||
# Interactive REPL mode
|
||||
sisyphus
|
||||
|
||||
# One-shot query
|
||||
sisyphus "Add a health check endpoint to the API"
|
||||
|
||||
# With custom models
|
||||
sisyphus --supervisor-model gpt-4o --explore-model gpt-4o-mini "Find auth patterns"
|
||||
|
||||
Environment variables:
|
||||
OPENAI_API_KEY — Required for OpenAI models
|
||||
ANTHROPIC_API_KEY — Required if using Anthropic models
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from sisyphus_langchain.graph import build_graph
|
||||
|
||||
|
||||
def run_query(graph, query: str, thread_id: str) -> str:
|
||||
"""
|
||||
Run a single query through the Sisyphus graph.
|
||||
|
||||
Args:
|
||||
graph: Compiled LangGraph.
|
||||
query: User's natural language request.
|
||||
thread_id: Session identifier for checkpointing.
|
||||
|
||||
Returns:
|
||||
The final output string.
|
||||
"""
|
||||
result = graph.invoke(
|
||||
{
|
||||
"messages": [HumanMessage(content=query)],
|
||||
"intent": "ambiguous",
|
||||
"next_agent": "",
|
||||
"iteration_count": 0,
|
||||
"todos": [],
|
||||
"agent_outputs": {},
|
||||
"final_output": "",
|
||||
"project_dir": ".",
|
||||
},
|
||||
config={
|
||||
"configurable": {"thread_id": thread_id},
|
||||
"recursion_limit": 50,
|
||||
},
|
||||
)
|
||||
return result.get("final_output", "(no output)")
|
||||
|
||||
|
||||
def repl(graph, thread_id: str) -> None:
|
||||
"""
|
||||
Interactive REPL loop — mirrors Loki's REPL mode.
|
||||
|
||||
Maintains conversation across turns via the thread_id (checkpointer).
|
||||
"""
|
||||
print("Sisyphus (LangChain) — type 'quit' to exit")
|
||||
print("=" * 50)
|
||||
|
||||
while True:
|
||||
try:
|
||||
query = input("\n> ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nBye.")
|
||||
break
|
||||
|
||||
if not query:
|
||||
continue
|
||||
if query.lower() in ("quit", "exit", "q"):
|
||||
print("Bye.")
|
||||
break
|
||||
|
||||
try:
|
||||
output = run_query(graph, query, thread_id)
|
||||
print(f"\n{output}")
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Sisyphus — multi-agent coding orchestrator (LangChain edition)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"query",
|
||||
nargs="?",
|
||||
help="One-shot query (omit for REPL mode)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--supervisor-model",
|
||||
default="gpt-4o",
|
||||
help="Model for the supervisor (default: gpt-4o)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--explore-model",
|
||||
default="gpt-4o-mini",
|
||||
help="Model for the explore agent (default: gpt-4o-mini)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--oracle-model",
|
||||
default="gpt-4o",
|
||||
help="Model for the oracle agent (default: gpt-4o)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--coder-model",
|
||||
default="gpt-4o",
|
||||
help="Model for the coder agent (default: gpt-4o)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--thread-id",
|
||||
default=None,
|
||||
help="Session thread ID for persistence (auto-generated if omitted)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
graph = build_graph(
|
||||
supervisor_model=args.supervisor_model,
|
||||
explore_model=args.explore_model,
|
||||
oracle_model=args.oracle_model,
|
||||
coder_model=args.coder_model,
|
||||
)
|
||||
|
||||
thread_id = args.thread_id or f"sisyphus-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
if args.query:
|
||||
output = run_query(graph, args.query, thread_id)
|
||||
print(output)
|
||||
else:
|
||||
repl(graph, thread_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Graph assembly — wires together the supervisor and worker nodes.
|
||||
|
||||
This is the LangGraph equivalent of Loki's runtime agent execution engine
|
||||
(src/supervisor/mod.rs + src/config/request_context.rs).
|
||||
|
||||
In Loki, the runtime:
|
||||
1. Loads the agent config (config.yaml)
|
||||
2. Compiles tools (tools.sh → binary)
|
||||
3. Starts a chat loop: user → LLM → tool calls → LLM → ...
|
||||
4. For orchestrators with can_spawn_agents: true, the supervisor module
|
||||
manages child agent lifecycle (spawn, check, collect, cancel).
|
||||
|
||||
In LangGraph, all of this is declarative:
|
||||
1. Define nodes (supervisor, explore, oracle, coder)
|
||||
2. Define edges (workers always return to supervisor)
|
||||
3. Compile the graph (with optional checkpointer for persistence)
|
||||
4. Invoke with initial state
|
||||
|
||||
The graph topology:
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ SUPERVISOR │
|
||||
│ (classifies intent, routes to workers) │
|
||||
└─────┬──────────┬──────────┬─────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│EXPLORE │ │ ORACLE │ │ CODER │
|
||||
│(search)│ │(advise)│ │(build) │
|
||||
└───┬────┘ └───┬────┘ └───┬────┘
|
||||
│ │ │
|
||||
└──────────┼──────────┘
|
||||
│
|
||||
(back to supervisor)
|
||||
|
||||
Every worker returns to the supervisor. The supervisor decides what to do next:
|
||||
route to another worker, or end the graph.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
|
||||
from sisyphus_langchain.agents.coder import create_coder_node
|
||||
from sisyphus_langchain.agents.explore import create_explore_node
|
||||
from sisyphus_langchain.agents.oracle import create_oracle_node
|
||||
from sisyphus_langchain.agents.supervisor import create_supervisor_node
|
||||
from sisyphus_langchain.state import SisyphusState
|
||||
|
||||
|
||||
def build_graph(
|
||||
*,
|
||||
supervisor_model: str = "gpt-4o",
|
||||
explore_model: str = "gpt-4o-mini",
|
||||
oracle_model: str = "gpt-4o",
|
||||
coder_model: str = "gpt-4o",
|
||||
use_checkpointer: bool = True,
|
||||
):
|
||||
"""
|
||||
Build and compile the Sisyphus LangGraph.
|
||||
|
||||
This is the main entry point for creating the agent system. It wires
|
||||
together all nodes and edges, optionally adds a checkpointer for
|
||||
persistence, and returns a compiled graph ready to invoke.
|
||||
|
||||
Args:
|
||||
supervisor_model: Model for the routing supervisor.
|
||||
explore_model: Model for the explore agent (can be cheaper).
|
||||
oracle_model: Model for the oracle agent (should be strong).
|
||||
coder_model: Model for the coder agent.
|
||||
use_checkpointer: Whether to add MemorySaver for session persistence.
|
||||
|
||||
Returns:
|
||||
A compiled LangGraph ready to .invoke() or .stream().
|
||||
|
||||
Model cost optimization (mirrors Loki's per-agent model config):
|
||||
- supervisor: expensive (accurate routing is critical)
|
||||
- explore: cheap (just searching, not reasoning deeply)
|
||||
- oracle: expensive (deep reasoning, architecture advice)
|
||||
- coder: expensive (writing correct code matters)
|
||||
"""
|
||||
# Create the graph builder with our typed state
|
||||
builder = StateGraph(SisyphusState)
|
||||
|
||||
# ── Register nodes ─────────────────────────────────────────────────
|
||||
# Each node is a function that takes state and returns state updates.
|
||||
# This mirrors Loki's agent registration (agents are discovered by
|
||||
# their config.yaml in the agents/ directory).
|
||||
builder.add_node("supervisor", create_supervisor_node(supervisor_model))
|
||||
builder.add_node("explore", create_explore_node(explore_model))
|
||||
builder.add_node("oracle", create_oracle_node(oracle_model))
|
||||
builder.add_node("coder", create_coder_node(coder_model))
|
||||
|
||||
# ── Define edges ───────────────────────────────────────────────────
|
||||
# Entry point: every invocation starts at the supervisor
|
||||
builder.add_edge(START, "supervisor")
|
||||
|
||||
# Workers always return to supervisor (the hub-and-spoke pattern).
|
||||
# In Loki, this is implicit: agent__collect returns output to the parent,
|
||||
# and the parent (sisyphus) decides what to do next.
|
||||
builder.add_edge("explore", "supervisor")
|
||||
builder.add_edge("oracle", "supervisor")
|
||||
builder.add_edge("coder", "supervisor")
|
||||
|
||||
# The supervisor node itself uses Command(goto=...) to route,
|
||||
# so we don't need add_conditional_edges — the Command API
|
||||
# handles dynamic routing internally.
|
||||
|
||||
# ── Compile ────────────────────────────────────────────────────────
|
||||
checkpointer = MemorySaver() if use_checkpointer else None
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
|
||||
return graph
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Shared state schema for the Sisyphus orchestrator graph.
|
||||
|
||||
In LangGraph, state is the single source of truth that flows through every node.
|
||||
This is analogous to Loki's per-agent session context, but unified into one typed
|
||||
dictionary that the entire graph shares.
|
||||
|
||||
Loki Concept Mapping:
|
||||
- Loki session context → SisyphusState (TypedDict)
|
||||
- Loki todo__init / todo__add → SisyphusState.todos list
|
||||
- Loki agent__spawn outputs → SisyphusState.agent_outputs dict
|
||||
- Loki intent classification → SisyphusState.intent field
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langgraph.graph.message import add_messages
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intent types — mirrors Loki's Sisyphus classification table
|
||||
# ---------------------------------------------------------------------------
|
||||
IntentType = Literal[
|
||||
"trivial", # Single file, known location, typo fix → handle yourself
|
||||
"exploration", # "Find X", "Where is Y" → spawn explore
|
||||
"implementation", # "Add feature", "Fix bug" → spawn coder
|
||||
"architecture", # Design questions, oracle triggers → spawn oracle
|
||||
"ambiguous", # Unclear scope → ask user
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Todo item — mirrors Loki's built-in todo system
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class TodoItem:
|
||||
"""A single task in the orchestrator's todo list."""
|
||||
id: int
|
||||
task: str
|
||||
done: bool = False
|
||||
|
||||
|
||||
def _merge_todos(existing: list[TodoItem], new: list[TodoItem]) -> list[TodoItem]:
|
||||
"""
|
||||
Reducer for the todos field.
|
||||
|
||||
LangGraph requires a reducer for any state field that can be written by
|
||||
multiple nodes. This merges by id: if a todo with the same id already
|
||||
exists, the incoming version wins (allows marking done).
|
||||
"""
|
||||
by_id = {t.id: t for t in existing}
|
||||
for t in new:
|
||||
by_id[t.id] = t
|
||||
return list(by_id.values())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core graph state
|
||||
# ---------------------------------------------------------------------------
|
||||
class SisyphusState(TypedDict):
|
||||
"""
|
||||
The shared state that flows through every node in the Sisyphus graph.
|
||||
|
||||
Annotated fields use *reducers* — functions that merge concurrent writes.
|
||||
Without reducers, parallel node outputs would overwrite each other.
|
||||
"""
|
||||
|
||||
# Conversation history — the `add_messages` reducer appends new messages
|
||||
# instead of replacing the list. This is critical: every node adds its
|
||||
# response here, and downstream nodes see the full history.
|
||||
#
|
||||
# Loki equivalent: each agent's chat session accumulates messages the same
|
||||
# way, but messages are scoped per-agent. In LangGraph the shared message
|
||||
# list IS the inter-agent communication channel.
|
||||
messages: Annotated[list[BaseMessage], add_messages]
|
||||
|
||||
# Classified intent for the current request
|
||||
intent: IntentType
|
||||
|
||||
# Which agent the supervisor routed to last
|
||||
next_agent: str
|
||||
|
||||
# Iteration counter — safety valve analogous to Loki's max_auto_continues
|
||||
iteration_count: int
|
||||
|
||||
# Todo list for multi-step tracking (mirrors Loki's todo__* tools)
|
||||
todos: Annotated[list[TodoItem], _merge_todos]
|
||||
|
||||
# Accumulated outputs from sub-agent nodes, keyed by agent name.
|
||||
# The supervisor reads these to decide what to do next.
|
||||
agent_outputs: dict[str, str]
|
||||
|
||||
# Final synthesized answer to return to the user
|
||||
final_output: str
|
||||
|
||||
# The working directory / project path (mirrors Loki's project_dir variable)
|
||||
project_dir: str
|
||||
@@ -0,0 +1 @@
|
||||
"""Tool definitions for Sisyphus agents."""
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Filesystem tools for Sisyphus agents.
|
||||
|
||||
These are the LangChain equivalents of Loki's global tools:
|
||||
- fs_read.sh → read_file
|
||||
- fs_grep.sh → search_content
|
||||
- fs_glob.sh → search_files
|
||||
- fs_ls.sh → list_directory
|
||||
- fs_write.sh → write_file
|
||||
- fs_patch.sh → (omitted — write_file covers full rewrites)
|
||||
|
||||
Loki Concept Mapping:
|
||||
Loki tools are bash scripts with @cmd annotations that Loki's compiler
|
||||
turns into function-calling declarations. In LangChain, we use the @tool
|
||||
decorator which serves the same purpose: it generates the JSON schema
|
||||
that the LLM sees, and wraps the Python function for execution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
|
||||
@tool
|
||||
def read_file(path: str, offset: int = 1, limit: int = 200) -> str:
|
||||
"""Read a file's contents with optional line range.
|
||||
|
||||
Args:
|
||||
path: Path to the file (absolute or relative to cwd).
|
||||
offset: 1-based line number to start from.
|
||||
limit: Maximum number of lines to return.
|
||||
"""
|
||||
path = os.path.expanduser(path)
|
||||
if not os.path.isfile(path):
|
||||
return f"Error: file not found: {path}"
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
except Exception as e:
|
||||
return f"Error reading {path}: {e}"
|
||||
|
||||
total = len(lines)
|
||||
start = max(0, offset - 1)
|
||||
end = min(total, start + limit)
|
||||
selected = lines[start:end]
|
||||
|
||||
result = f"File: {path} (lines {start + 1}-{end} of {total})\n\n"
|
||||
for i, line in enumerate(selected, start=start + 1):
|
||||
result += f"{i}: {line}"
|
||||
|
||||
if end < total:
|
||||
result += f"\n... truncated ({total} total lines)"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@tool
|
||||
def write_file(path: str, content: str) -> str:
|
||||
"""Write complete contents to a file, creating parent directories as needed.
|
||||
|
||||
Args:
|
||||
path: Path for the file.
|
||||
content: Complete file contents to write.
|
||||
"""
|
||||
path = os.path.expanduser(path)
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return f"Wrote: {path}"
|
||||
except Exception as e:
|
||||
return f"Error writing {path}: {e}"
|
||||
|
||||
|
||||
@tool
|
||||
def search_content(pattern: str, directory: str = ".", file_type: str = "") -> str:
|
||||
"""Search for a text/regex pattern in files under a directory.
|
||||
|
||||
Args:
|
||||
pattern: Text or regex pattern to search for.
|
||||
directory: Root directory to search in.
|
||||
file_type: Optional file extension filter (e.g. "py", "rs").
|
||||
"""
|
||||
directory = os.path.expanduser(directory)
|
||||
cmd = ["grep", "-rn"]
|
||||
if file_type:
|
||||
cmd += [f"--include=*.{file_type}"]
|
||||
cmd += [pattern, directory]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
lines = result.stdout.strip().splitlines()
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Filter noise
|
||||
noise = {"/.git/", "/node_modules/", "/target/", "/dist/", "/__pycache__/"}
|
||||
filtered = [l for l in lines if not any(n in l for n in noise)][:30]
|
||||
|
||||
if not filtered:
|
||||
return "No matches found."
|
||||
return "\n".join(filtered)
|
||||
|
||||
|
||||
@tool
|
||||
def search_files(pattern: str, directory: str = ".") -> str:
|
||||
"""Find files matching a glob pattern.
|
||||
|
||||
Args:
|
||||
pattern: Glob pattern (e.g. '*.py', 'config*', '*test*').
|
||||
directory: Directory to search in.
|
||||
"""
|
||||
directory = os.path.expanduser(directory)
|
||||
noise = {".git", "node_modules", "target", "dist", "__pycache__"}
|
||||
matches: list[str] = []
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
dirs[:] = [d for d in dirs if d not in noise]
|
||||
for name in files:
|
||||
if fnmatch.fnmatch(name, pattern):
|
||||
matches.append(os.path.join(root, name))
|
||||
if len(matches) >= 25:
|
||||
break
|
||||
if len(matches) >= 25:
|
||||
break
|
||||
|
||||
if not matches:
|
||||
return "No files found."
|
||||
return "\n".join(matches)
|
||||
|
||||
|
||||
@tool
|
||||
def list_directory(path: str = ".", max_depth: int = 3) -> str:
|
||||
"""List directory tree structure.
|
||||
|
||||
Args:
|
||||
path: Directory to list.
|
||||
max_depth: Maximum depth to recurse.
|
||||
"""
|
||||
path = os.path.expanduser(path)
|
||||
if not os.path.isdir(path):
|
||||
return f"Error: not a directory: {path}"
|
||||
|
||||
noise = {".git", "node_modules", "target", "dist", "__pycache__", ".venv", "venv"}
|
||||
lines: list[str] = []
|
||||
|
||||
def _walk(dir_path: str, prefix: str, depth: int) -> None:
|
||||
if depth > max_depth:
|
||||
return
|
||||
try:
|
||||
entries = sorted(os.listdir(dir_path))
|
||||
except PermissionError:
|
||||
return
|
||||
|
||||
dirs = [e for e in entries if os.path.isdir(os.path.join(dir_path, e)) and e not in noise]
|
||||
files = [e for e in entries if os.path.isfile(os.path.join(dir_path, e))]
|
||||
|
||||
for f in files[:20]:
|
||||
lines.append(f"{prefix}{f}")
|
||||
if len(files) > 20:
|
||||
lines.append(f"{prefix}... ({len(files) - 20} more files)")
|
||||
|
||||
for d in dirs:
|
||||
lines.append(f"{prefix}{d}/")
|
||||
_walk(os.path.join(dir_path, d), prefix + " ", depth + 1)
|
||||
|
||||
lines.append(f"{os.path.basename(path) or path}/")
|
||||
_walk(path, " ", 1)
|
||||
return "\n".join(lines[:200])
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Project detection and build/test tools.
|
||||
|
||||
These mirror Loki's .shared/utils.sh detect_project() heuristic and the
|
||||
sisyphus/coder tools.sh run_build / run_tests / verify_build commands.
|
||||
|
||||
Loki Concept Mapping:
|
||||
Loki uses a heuristic cascade: check for Cargo.toml → go.mod → package.json
|
||||
etc., then falls back to an LLM call for unknown projects. We replicate the
|
||||
heuristic portion here. The LLM fallback is omitted since the agents
|
||||
themselves can reason about unknown project types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project detection (mirrors _detect_heuristic in utils.sh)
|
||||
# ---------------------------------------------------------------------------
|
||||
_HEURISTICS: list[tuple[str, dict[str, str]]] = [
|
||||
("Cargo.toml", {"type": "rust", "build": "cargo build", "test": "cargo test", "check": "cargo check"}),
|
||||
("go.mod", {"type": "go", "build": "go build ./...", "test": "go test ./...", "check": "go vet ./..."}),
|
||||
("package.json", {"type": "nodejs", "build": "npm run build", "test": "npm test", "check": "npm run lint"}),
|
||||
("pyproject.toml", {"type": "python", "build": "", "test": "pytest", "check": "ruff check ."}),
|
||||
("pom.xml", {"type": "java", "build": "mvn compile", "test": "mvn test", "check": "mvn verify"}),
|
||||
("Makefile", {"type": "make", "build": "make build", "test": "make test", "check": "make lint"}),
|
||||
]
|
||||
|
||||
|
||||
def detect_project(directory: str) -> dict[str, str]:
|
||||
"""Detect project type and return build/test commands."""
|
||||
for marker, info in _HEURISTICS:
|
||||
if os.path.exists(os.path.join(directory, marker)):
|
||||
return info
|
||||
return {"type": "unknown", "build": "", "test": "", "check": ""}
|
||||
|
||||
|
||||
@tool
|
||||
def get_project_info(directory: str = ".") -> str:
|
||||
"""Detect the project type and show structure overview.
|
||||
|
||||
Args:
|
||||
directory: Project root directory.
|
||||
"""
|
||||
directory = os.path.expanduser(directory)
|
||||
info = detect_project(directory)
|
||||
result = f"Project: {os.path.abspath(directory)}\n"
|
||||
result += f"Type: {info['type']}\n"
|
||||
result += f"Build: {info['build'] or '(none)'}\n"
|
||||
result += f"Test: {info['test'] or '(none)'}\n"
|
||||
result += f"Check: {info['check'] or '(none)'}\n"
|
||||
return result
|
||||
|
||||
|
||||
def _run_project_command(directory: str, command_key: str) -> str:
|
||||
"""Run a detected project command (build/test/check)."""
|
||||
directory = os.path.expanduser(directory)
|
||||
info = detect_project(directory)
|
||||
cmd = info.get(command_key, "")
|
||||
|
||||
if not cmd:
|
||||
return f"No {command_key} command detected for this project."
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=directory,
|
||||
timeout=300,
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
status = "SUCCESS" if result.returncode == 0 else f"FAILED (exit {result.returncode})"
|
||||
return f"Running: {cmd}\n\n{output}\n\n{command_key.upper()}: {status}"
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"{command_key.upper()}: TIMEOUT after 300s"
|
||||
except Exception as e:
|
||||
return f"{command_key.upper()}: ERROR — {e}"
|
||||
|
||||
|
||||
@tool
|
||||
def run_build(directory: str = ".") -> str:
|
||||
"""Run the project's build command.
|
||||
|
||||
Args:
|
||||
directory: Project root directory.
|
||||
"""
|
||||
return _run_project_command(directory, "build")
|
||||
|
||||
|
||||
@tool
|
||||
def run_tests(directory: str = ".") -> str:
|
||||
"""Run the project's test suite.
|
||||
|
||||
Args:
|
||||
directory: Project root directory.
|
||||
"""
|
||||
return _run_project_command(directory, "test")
|
||||
|
||||
|
||||
@tool
|
||||
def verify_build(directory: str = ".") -> str:
|
||||
"""Run the project's check/lint command to verify correctness.
|
||||
|
||||
Args:
|
||||
directory: Project root directory.
|
||||
"""
|
||||
return _run_project_command(directory, "check")
|
||||
|
||||
|
||||
@tool
|
||||
def execute_command(command: str, directory: str = ".") -> str:
|
||||
"""Execute a shell command and return its output.
|
||||
|
||||
Args:
|
||||
command: Shell command to execute.
|
||||
directory: Working directory.
|
||||
"""
|
||||
directory = os.path.expanduser(directory)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=directory,
|
||||
timeout=120,
|
||||
)
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
if result.returncode != 0:
|
||||
return f"Command failed (exit {result.returncode}):\n{output}"
|
||||
return output or "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Command timed out after 120s."
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
@@ -342,7 +342,7 @@ mod tests {
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream;
|
||||
use rand::Rng;
|
||||
use rand::random_range;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -392,10 +392,9 @@ mod tests {
|
||||
}
|
||||
|
||||
fn split_chunks(text: &str) -> Vec<Vec<u8>> {
|
||||
let mut rng = rand::rng();
|
||||
let len = text.len();
|
||||
let cut1 = rng.random_range(1..len - 1);
|
||||
let cut2 = rng.random_range(cut1 + 1..len);
|
||||
let cut1 = random_range(1..len - 1);
|
||||
let cut2 = random_range(cut1 + 1..len);
|
||||
let chunk1 = text.as_bytes()[..cut1].to_vec();
|
||||
let chunk2 = text.as_bytes()[cut1..cut2].to_vec();
|
||||
let chunk3 = text.as_bytes()[cut2..].to_vec();
|
||||
|
||||
+1
-1
@@ -584,7 +584,7 @@ impl Config {
|
||||
}
|
||||
|
||||
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))? {
|
||||
let entry = entry?;
|
||||
|
||||
+117
-35
@@ -12,7 +12,7 @@ use crate::mcp::{
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
|
||||
};
|
||||
use crate::parsers::{bash, python};
|
||||
use crate::parsers::{bash, python, typescript};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use indexmap::IndexMap;
|
||||
use indoc::formatdoc;
|
||||
@@ -53,6 +53,7 @@ enum BinaryType<'a> {
|
||||
enum Language {
|
||||
Bash,
|
||||
Python,
|
||||
TypeScript,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
@@ -61,6 +62,7 @@ impl From<&String> for Language {
|
||||
match s.to_lowercase().as_str() {
|
||||
"sh" => Language::Bash,
|
||||
"py" => Language::Python,
|
||||
"ts" => Language::TypeScript,
|
||||
_ => Language::Unsupported,
|
||||
}
|
||||
}
|
||||
@@ -72,6 +74,7 @@ impl Language {
|
||||
match self {
|
||||
Language::Bash => "bash",
|
||||
Language::Python => "python",
|
||||
Language::TypeScript => "npx tsx",
|
||||
Language::Unsupported => "sh",
|
||||
}
|
||||
}
|
||||
@@ -80,11 +83,32 @@ impl Language {
|
||||
match self {
|
||||
Language::Bash => "sh",
|
||||
Language::Python => "py",
|
||||
Language::TypeScript => "ts",
|
||||
_ => "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(
|
||||
config: &GlobalConfig,
|
||||
mut calls: Vec<ToolCall>,
|
||||
@@ -473,6 +497,11 @@ impl Functions {
|
||||
file_name,
|
||||
tools_file_path.parent(),
|
||||
),
|
||||
Language::TypeScript => typescript::generate_typescript_declarations(
|
||||
tool_file,
|
||||
file_name,
|
||||
tools_file_path.parent(),
|
||||
),
|
||||
Language::Unsupported => {
|
||||
bail!("Unsupported tool file extension: {}", language.as_ref())
|
||||
}
|
||||
@@ -513,7 +542,14 @@ impl Functions {
|
||||
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(())
|
||||
@@ -554,8 +590,9 @@ impl Functions {
|
||||
}
|
||||
|
||||
fn build_agent_tool_binaries(name: &str) -> Result<()> {
|
||||
let tools_file = Config::agent_functions_file(name)?;
|
||||
let language = Language::from(
|
||||
&Config::agent_functions_file(name)?
|
||||
&tools_file
|
||||
.extension()
|
||||
.and_then(OsStr::to_str)
|
||||
.map(|s| s.to_lowercase())
|
||||
@@ -568,7 +605,8 @@ impl Functions {
|
||||
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)]
|
||||
@@ -576,6 +614,7 @@ impl Functions {
|
||||
binary_name: &str,
|
||||
language: Language,
|
||||
binary_type: BinaryType,
|
||||
custom_runtime: Option<&str>,
|
||||
) -> Result<()> {
|
||||
use native::runtime;
|
||||
let (binary_file, binary_script_file) = match binary_type {
|
||||
@@ -660,31 +699,42 @@ impl Functions {
|
||||
binary_file.display()
|
||||
);
|
||||
|
||||
let run = match language {
|
||||
Language::Bash => {
|
||||
let shell = runtime::bash_path().ok_or_else(|| anyhow!("Shell not found"))?;
|
||||
format!("{shell} --noprofile --norc")
|
||||
let run = if let Some(rt) = custom_runtime {
|
||||
rt.to_string()
|
||||
} else {
|
||||
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 = 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()
|
||||
}
|
||||
_ => bail!("Unsupported language: {}", language.as_ref()),
|
||||
};
|
||||
let bin_dir = binary_file
|
||||
.parent()
|
||||
@@ -714,6 +764,7 @@ impl Functions {
|
||||
binary_name: &str,
|
||||
language: Language,
|
||||
binary_type: BinaryType,
|
||||
custom_runtime: Option<&str>,
|
||||
) -> Result<()> {
|
||||
use std::os::unix::prelude::PermissionsExt;
|
||||
|
||||
@@ -742,7 +793,7 @@ impl Functions {
|
||||
)
|
||||
})?;
|
||||
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) => {
|
||||
let root_dir = Config::functions_dir();
|
||||
let tool_path = format!(
|
||||
@@ -773,13 +824,44 @@ impl Functions {
|
||||
"{prompt_utils_file}",
|
||||
&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(())
|
||||
}
|
||||
|
||||
+1
-34
@@ -17,15 +17,9 @@ pub(crate) struct Param {
|
||||
pub(crate) trait ScriptedLanguage {
|
||||
fn ts_language(&self) -> tree_sitter::Language;
|
||||
|
||||
fn default_runtime(&self) -> &str;
|
||||
|
||||
fn lang_name(&self) -> &str;
|
||||
|
||||
fn find_functions<'a>(
|
||||
&self,
|
||||
root: Node<'a>,
|
||||
src: &str,
|
||||
) -> Vec<(Node<'a>, Node<'a>)>;
|
||||
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>;
|
||||
|
||||
@@ -175,31 +169,6 @@ pub(crate) fn named_child(node: Node<'_>, index: usize) -> Option<Node<'_>> {
|
||||
node.named_children(&mut cursor).nth(index)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_runtime(tree: &tree_sitter::Tree, src: &str, default: &str) -> String {
|
||||
let root = tree.root_node();
|
||||
let mut cursor = root.walk();
|
||||
for child in root.named_children(&mut cursor) {
|
||||
let text = match child.kind() {
|
||||
"hash_bang_line" | "comment" => match child.utf8_text(src.as_bytes()) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
},
|
||||
_ => break,
|
||||
};
|
||||
|
||||
if let Some(cmd) = text.strip_prefix("#!") {
|
||||
let cmd = cmd.trim();
|
||||
if let Some(after_env) = cmd.strip_prefix("/usr/bin/env ") {
|
||||
return after_env.trim().to_string();
|
||||
}
|
||||
return cmd.to_string();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn generate_declarations<L: ScriptedLanguage>(
|
||||
lang: &L,
|
||||
src: &str,
|
||||
@@ -226,8 +195,6 @@ pub(crate) fn generate_declarations<L: ScriptedLanguage>(
|
||||
);
|
||||
}
|
||||
|
||||
let _runtime = extract_runtime(&tree, src, lang.default_runtime());
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (wrapper, func) in lang.find_functions(tree.root_node(), src) {
|
||||
let func_name = lang.function_name(func, src)?;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub(crate) mod bash;
|
||||
pub(crate) mod common;
|
||||
pub(crate) mod python;
|
||||
pub(crate) mod typescript;
|
||||
|
||||
@@ -15,10 +15,6 @@ impl ScriptedLanguage for PythonLanguage {
|
||||
tree_sitter_python::LANGUAGE.into()
|
||||
}
|
||||
|
||||
fn default_runtime(&self) -> &str {
|
||||
"python"
|
||||
}
|
||||
|
||||
fn lang_name(&self) -> &str {
|
||||
"python"
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user