|
#!/usr/bin/env python3 |
|
""" |
|
skill_agent.py — A SKILL.md-guided agentic file editor over an Ollama-compatible API. |
|
|
|
Usage: |
|
python skill_agent.py \\ |
|
--api-key <key> \\ |
|
--skill path/to/SKILL.md \\ |
|
--task "your task here" \\ |
|
[--work-dir ./my_files] \\ |
|
[--host https://...] \\ |
|
[--model qwen3:235b] \\ |
|
[--chunk-size 100] \\ |
|
[--history-turns 8] \\ |
|
[--max-tool-result 4000] \\ |
|
[--max-payload-bytes 200000] \\ |
|
[--list-models] \\ |
|
[--verbose] |
|
""" |
|
|
|
import json |
|
import re |
|
import sys |
|
from pathlib import Path |
|
from argparse import ArgumentParser, RawDescriptionHelpFormatter |
|
from ollama import Client |
|
import urllib.request |
|
import urllib.parse |
|
from datetime import datetime |
|
|
|
# ── Defaults ────────────────────────────────────────────────────────────────── |
|
|
|
DEFAULT_HOST = "https://rcsllm.carleton.ca/rcsapi" |
|
DEFAULT_MODEL = "qwen3.5:122b" |
|
MAX_TURNS = 100 |
|
DEFAULT_CHUNK_LINES = 100 |
|
DEFAULT_HISTORY_TURNS = 20 |
|
DEFAULT_MAX_TOOL_RESULT = 4_000 # chars stored per tool result in history |
|
DEFAULT_MAX_PAYLOAD_BYTES = 200_000 # pre-flight serialized payload cap (bytes) |
|
|
|
# ── Tool schema (Ollama/OpenAI function-calling format) ─────────────────────── |
|
|
|
TOOLS = [ |
|
{ |
|
"type": "function", |
|
"function": { |
|
"name": "list_files", |
|
"description": ( |
|
"List files in a directory relative to the working directory. " |
|
"Use this to discover what files are available before reading them." |
|
), |
|
"parameters": { |
|
"type": "object", |
|
"properties": { |
|
"directory": { |
|
"type": "string", |
|
"description": "Relative path to directory. Use '.' for the working directory.", |
|
"default": "." |
|
}, |
|
"pattern": { |
|
"type": "string", |
|
"description": "Glob pattern to filter results (e.g. '*.md', '**/*.txt').", |
|
"default": "**/*" |
|
} |
|
}, |
|
"required": [] |
|
} |
|
} |
|
}, |
|
{ |
|
"type": "function", |
|
"function": { |
|
"name": "grep_file", |
|
"description": ( |
|
"Search for a regular expression inside a file and return matching lines " |
|
"with surrounding context. Line numbers are 1-indexed." |
|
), |
|
"parameters": { |
|
"type": "object", |
|
"properties": { |
|
"path": { |
|
"type": "string", |
|
"description": "Relative path to the file." |
|
}, |
|
"pattern": { |
|
"type": "string", |
|
"description": "Regular expression to search for." |
|
}, |
|
"context_lines": { |
|
"type": "integer", |
|
"description": "Lines of context around each match.", |
|
"default": 2 |
|
} |
|
}, |
|
"required": ["path", "pattern"] |
|
} |
|
} |
|
}, |
|
{ |
|
"type": "function", |
|
"function": { |
|
"name": "read_file", |
|
"description": ( |
|
"Read a chunk of a file's text. Use offset/limit to paginate through large files. " |
|
"For discovery, use grep_file first, then read the relevant chunk." |
|
), |
|
"parameters": { |
|
"type": "object", |
|
"properties": { |
|
"path": { |
|
"type": "string", |
|
"description": "Relative path to the file." |
|
}, |
|
"offset": { |
|
"type": "integer", |
|
"description": "1-indexed starting line. Default 1.", |
|
"default": 1 |
|
}, |
|
"limit": { |
|
"type": "integer", |
|
"description": "Maximum number of lines to read.", |
|
"default": DEFAULT_CHUNK_LINES |
|
} |
|
}, |
|
"required": ["path"] |
|
} |
|
} |
|
}, |
|
{ |
|
"type": "function", |
|
"function": { |
|
"name": "str_replace", |
|
"description": ( |
|
"Replace a unique string snippet in a file with new text. " |
|
"The old_str must appear exactly once; otherwise an error is returned. " |
|
"Use this for surgical edits instead of write_file whenever possible." |
|
), |
|
"parameters": { |
|
"type": "object", |
|
"properties": { |
|
"path": { |
|
"type": "string", |
|
"description": "Relative path to the file." |
|
}, |
|
"old_str": { |
|
"type": "string", |
|
"description": "Exact existing snippet to replace (must be unique in the file)." |
|
}, |
|
"new_str": { |
|
"type": "string", |
|
"description": "Text to substitute for old_str." |
|
} |
|
}, |
|
"required": ["path", "old_str", "new_str"] |
|
} |
|
} |
|
}, |
|
{ |
|
"type": "function", |
|
"function": { |
|
"name": "write_file", |
|
"description": ( |
|
"Write content to a file, creating it or overwriting it. " |
|
"Parent directories are created automatically. " |
|
"IMPORTANT: You must NEVER use this to delete content — " |
|
"only to create or update files. Prefer str_replace for large files." |
|
), |
|
"parameters": { |
|
"type": "object", |
|
"properties": { |
|
"path": { |
|
"type": "string", |
|
"description": "Relative path to the file to write." |
|
}, |
|
"content": { |
|
"type": "string", |
|
"description": "The full content to write to the file." |
|
} |
|
}, |
|
"required": ["path", "content"] |
|
} |
|
} |
|
}, |
|
{ |
|
"type": "function", |
|
"function": { |
|
"name": "append_file", |
|
"description": "Append text to the end of an existing file (or create it if absent).", |
|
"parameters": { |
|
"type": "object", |
|
"properties": { |
|
"path": { |
|
"type": "string", |
|
"description": "Relative path to the file." |
|
}, |
|
"content": { |
|
"type": "string", |
|
"description": "Text to append." |
|
} |
|
}, |
|
"required": ["path", "content"] |
|
} |
|
} |
|
}, |
|
{ |
|
"type": "function", |
|
"function": { |
|
"name": "wikidata_search", |
|
"description": ( |
|
"Search Wikidata for an entity to find its canonical Q-identifier and description. " |
|
"Use this to reconcile entities to global knowledge graph identifiers." |
|
), |
|
"parameters": { |
|
"type": "object", |
|
"properties": { |
|
"query": { |
|
"type": "string", |
|
"description": "The name of the entity to search for (e.g., 'Survey of India')." |
|
}, |
|
"limit": { |
|
"type": "integer", |
|
"description": "Number of results to return.", |
|
"default": 3 |
|
} |
|
}, |
|
"required": ["query"] |
|
} |
|
} |
|
} |
|
] |
|
|
|
# ── Helpers ─────────────────────────────────────────────────────────────────── |
|
|
|
def _msg_role(msg) -> str | None: |
|
"""Return the role of a message regardless of whether it's a dict or SDK object.""" |
|
if isinstance(msg, dict): |
|
return msg.get("role") |
|
return getattr(msg, "role", None) |
|
|
|
|
|
def prune_messages(messages, max_turns: int): |
|
""" |
|
Retain only the initial user task plus the last *max_turns* complete |
|
assistant/tool cycles. This prevents the context window from growing |
|
indefinitely on long tasks. |
|
""" |
|
if len(messages) <= 1: |
|
return messages |
|
|
|
# messages[0] is the original user task — always keep it. |
|
task_msg = messages[0] |
|
|
|
# Group remaining messages into (assistant_msg, [tool_msgs, ...]) tuples. |
|
turns = [] |
|
i = 1 |
|
while i < len(messages): |
|
if _msg_role(messages[i]) == "assistant": |
|
assistant_msg = messages[i] |
|
tool_msgs = [] |
|
i += 1 |
|
while i < len(messages) and _msg_role(messages[i]) == "tool": |
|
tool_msgs.append(messages[i]) |
|
i += 1 |
|
turns.append((assistant_msg, tool_msgs)) |
|
else: |
|
# Unexpected message type; skip it to stay robust. |
|
i += 1 |
|
|
|
kept = [task_msg] |
|
for assistant_msg, tool_msgs in turns[-max_turns:]: |
|
kept.append(assistant_msg) |
|
kept.extend(tool_msgs) |
|
return kept |
|
|
|
|
|
def _to_serializable(msg) -> dict: |
|
""" |
|
Convert a message to a plain dict for json.dumps size estimation. |
|
Handles both raw dicts (user/tool messages) and Ollama SDK Message |
|
objects (Pydantic models returned by client.chat). |
|
""" |
|
if isinstance(msg, dict): |
|
return msg |
|
if hasattr(msg, "model_dump"): # Pydantic v2 (ollama SDK >= 0.2) |
|
return msg.model_dump() |
|
if hasattr(msg, "dict"): # Pydantic v1 fallback |
|
return msg.dict() |
|
return vars(msg) # last resort |
|
|
|
|
|
def _payload_bytes(system_prompt: str, history: list) -> int: |
|
"""Return the UTF-8 byte length of the serialized chat payload.""" |
|
serializable = [{"role": "system", "content": system_prompt}] + [ |
|
_to_serializable(m) for m in history |
|
] |
|
return len(json.dumps(serializable).encode("utf-8")) |
|
|
|
|
|
def enforce_payload_limit( |
|
system_prompt: str, |
|
active_history: list, |
|
history_turns: int, |
|
max_payload_bytes: int, |
|
) -> list: |
|
""" |
|
Pre-flight check: if the serialized payload exceeds max_payload_bytes, |
|
iteratively drop the oldest non-task turns until it fits (or only the |
|
task message remains). |
|
|
|
The system prompt (which contains the full SKILL) is never modified — |
|
it is a fixed cost. If even a bare payload (system prompt + task only) |
|
exceeds the limit, emit a warning and proceed; the server error is more |
|
informative than silent truncation of the skill. |
|
""" |
|
while len(active_history) > 1: |
|
if _payload_bytes(system_prompt, active_history) <= max_payload_bytes: |
|
break |
|
history_turns = max(1, history_turns - 1) |
|
active_history = prune_messages(active_history, max_turns=history_turns) |
|
|
|
# Warn if the irreducible payload (skill + task only) is already over budget. |
|
bare_bytes = _payload_bytes(system_prompt, active_history[:1]) |
|
if bare_bytes > max_payload_bytes: |
|
print( |
|
f"[WARN] Skill + task alone ({bare_bytes:,} bytes) " |
|
f"exceeds --max-payload-bytes ({max_payload_bytes:,}). " |
|
f"Increase --max-payload-bytes to match your server's limit." |
|
) |
|
|
|
return active_history |
|
|
|
|
|
def truncate_tool_result(result: str, max_chars: int) -> str: |
|
"""Cap a tool result to max_chars, appending a truncation notice if needed.""" |
|
if len(result) <= max_chars: |
|
return result |
|
return result[:max_chars] + f"\n… [truncated — {len(result)} chars total]" |
|
|
|
|
|
# ── Tool execution ──────────────────────────────────────────────────────────── |
|
|
|
def execute_tool(name: str, args: dict, work_dir: Path, chunk_size: int) -> str: |
|
"""Dispatch a tool call and return its string result.""" |
|
|
|
if name == "list_files": |
|
directory = work_dir / args.get("directory", ".") |
|
pattern = args.get("pattern", "**/*") |
|
try: |
|
matches = sorted(p for p in directory.glob(pattern) if p.is_file()) |
|
except Exception as e: |
|
return f"ERROR: {e}" |
|
if not matches: |
|
return "(no files matched)" |
|
return "\n".join(str(p.relative_to(work_dir)) for p in matches) |
|
|
|
elif name == "grep_file": |
|
missing = [k for k in ("path", "pattern") if k not in args] |
|
if missing: |
|
return f"ERROR: missing required argument(s): {missing}. grep_file requires 'path' and 'pattern'." |
|
path = work_dir / args["path"] |
|
pattern = args["pattern"] |
|
ctx = int(args.get("context_lines", 2)) |
|
try: |
|
text = path.read_text(encoding="utf-8") |
|
lines = text.splitlines() |
|
try: |
|
regex = re.compile(pattern) |
|
except re.error as e: |
|
return f"ERROR: invalid regex — {e}" |
|
matched = set() |
|
for i, line in enumerate(lines): |
|
if regex.search(line): |
|
matched.update(range(max(0, i - ctx), min(len(lines), i + ctx + 1))) |
|
if not matched: |
|
return "(no matches)" |
|
out = [] |
|
for i in sorted(matched): |
|
prefix = ">>> " if regex.search(lines[i]) else " " |
|
out.append(f"{prefix}{i + 1:4d}: {lines[i]}") |
|
return "\n".join(out) |
|
except FileNotFoundError: |
|
return f"ERROR: file not found — {path}" |
|
except Exception as e: |
|
return f"ERROR: {e}" |
|
|
|
elif name == "read_file": |
|
if "path" not in args: |
|
return "ERROR: missing required argument 'path'. read_file requires 'path'." |
|
path = work_dir / args["path"] |
|
try: |
|
text = path.read_text(encoding="utf-8") |
|
lines = text.splitlines() |
|
total = len(lines) |
|
|
|
offset = int(args.get("offset", 1)) |
|
limit = int(args.get("limit", chunk_size)) |
|
if offset < 1: |
|
offset = 1 |
|
start = offset - 1 # 0-indexed |
|
end = start + limit |
|
|
|
if total == 0: |
|
return "(empty file)" |
|
if start >= total: |
|
return f"[requested lines {offset}+ of {total}]\nERROR: offset past end of file" |
|
|
|
chunk = lines[start:end] |
|
header = f"[lines {offset}-{min(end, total)} of {total}]" |
|
return f"{header}\n" + "\n".join(chunk) |
|
except FileNotFoundError: |
|
return f"ERROR: file not found — {path}" |
|
except Exception as e: |
|
return f"ERROR: {e}" |
|
|
|
elif name == "str_replace": |
|
missing = [k for k in ("path", "old_str", "new_str") if k not in args] |
|
if missing: |
|
return f"ERROR: missing required argument(s): {missing}. str_replace requires 'path', 'old_str', and 'new_str'." |
|
path = work_dir / args["path"] |
|
old_str = args["old_str"] |
|
new_str = args["new_str"] |
|
try: |
|
original = path.read_text(encoding="utf-8") |
|
count = original.count(old_str) |
|
if count == 0: |
|
return "ERROR: old_str not found in file" |
|
if count > 1: |
|
return f"ERROR: old_str is ambiguous (found {count} times). Provide a larger unique snippet." |
|
updated = original.replace(old_str, new_str, 1) |
|
path.write_text(updated, encoding="utf-8") |
|
return f"OK: replaced {len(old_str)} chars with {len(new_str)} chars → {path.relative_to(work_dir)}" |
|
except FileNotFoundError: |
|
return f"ERROR: file not found — {path}" |
|
except Exception as e: |
|
return f"ERROR: {e}" |
|
|
|
elif name == "write_file": |
|
missing = [k for k in ("path", "content") if k not in args] |
|
if missing: |
|
return f"ERROR: missing required argument(s): {missing}. write_file requires 'path' and 'content'." |
|
path = work_dir / args["path"] |
|
try: |
|
path.parent.mkdir(parents=True, exist_ok=True) |
|
path.write_text(args["content"], encoding="utf-8") |
|
return f"OK: wrote {len(args['content'])} chars → {path.relative_to(work_dir)}" |
|
except Exception as e: |
|
return f"ERROR: {e}" |
|
|
|
elif name == "append_file": |
|
missing = [k for k in ("path", "content") if k not in args] |
|
if missing: |
|
return f"ERROR: missing required argument(s): {missing}. append_file requires 'path' and 'content'." |
|
path = work_dir / args["path"] |
|
try: |
|
path.parent.mkdir(parents=True, exist_ok=True) |
|
with path.open("a", encoding="utf-8") as f: |
|
f.write(args["content"]) |
|
return f"OK: appended {len(args['content'])} chars → {path.relative_to(work_dir)}" |
|
except Exception as e: |
|
return f"ERROR: {e}" |
|
|
|
elif name == "wikidata_search": |
|
if "query" not in args: |
|
return "ERROR: missing required argument 'query'." |
|
|
|
query = args["query"] |
|
limit = int(args.get("limit", 3)) |
|
|
|
# Wikidata wbsearchentities API |
|
params = { |
|
"action": "wbsearchentities", |
|
"search": query, |
|
"language": "en", |
|
"format": "json", |
|
"limit": limit |
|
} |
|
url = "https://www.wikidata.org/w/api.php?" + urllib.parse.urlencode(params) |
|
|
|
try: |
|
# Wikidata requires a user-agent |
|
req = urllib.request.Request(url, headers={'User-Agent': 'SkillAgent/1.0 (Agentic KG Builder)'}) |
|
with urllib.request.urlopen(req) as response: |
|
data = json.loads(response.read().decode('utf-8')) |
|
|
|
search_results = data.get("search", []) |
|
if not search_results: |
|
return f"No Wikidata matches found for '{query}'." |
|
|
|
out = [f"Wikidata search results for '{query}':"] |
|
for r in search_results: |
|
qid = r.get("id") |
|
label = r.get("label", "No label") |
|
desc = r.get("description", "No description provided") |
|
out.append(f" - {qid}: {label} ({desc})") |
|
|
|
return "\n".join(out) |
|
except Exception as e: |
|
return f"ERROR querying Wikidata: {e}" |
|
|
|
return f"ERROR: unknown tool '{name}'" |
|
|
|
# ── Agent loop ──────────────────────────────────────────────────────────────── |
|
|
|
def build_system_prompt(skill_text: str, chunk_size: int, history_turns: int) -> str: |
|
# Grab the actual system time formatted nicely |
|
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
|
return f"""You are an autonomous file-editing agent operating inside a defined working directory. |
|
Current System Time: {current_time} |
|
|
|
You have the following tools: |
|
• list_files — discover available files |
|
• grep_file — search a file with regex (returns line numbers + context) |
|
• read_file — read a chunk of a file using offset/limit (default limit {chunk_size}) |
|
• str_replace — replace a unique snippet inside a file (preferred for edits) |
|
• write_file — overwrite or create a file (use for new files or full rewrites only) |
|
• append_file — append text to the end of a file |
|
• wikidata_search — search Wikidata to get canonical Q-IDs and descriptions for entities |
|
|
|
Rules: |
|
1. NEVER delete files or remove content without replacement. |
|
2. For large files, use grep_file to locate content, then read_file in chunks. |
|
3. Prefer str_replace over write_file so you don't accidentally destroy unchanged content. |
|
4. Only the most recent {history_turns} tool cycles are remembered. Re-read files if you need earlier context. |
|
|
|
Follow the SKILL definition below precisely. |
|
When the task is fully complete, respond with a concise plain-text summary of every file you created or modified. Do not call any further tools. |
|
|
|
═══ SKILL ═══ |
|
{skill_text} |
|
═══════════════""" |
|
|
|
|
|
def run_agent( |
|
client: Client, |
|
model: str, |
|
skill_text: str, |
|
task: str, |
|
work_dir: Path, |
|
verbose: bool, |
|
chunk_size: int, |
|
history_turns: int, |
|
max_tool_result: int, |
|
max_payload_bytes: int, |
|
) -> None: |
|
|
|
system_prompt = build_system_prompt(skill_text, chunk_size, history_turns) |
|
skill_bytes = len(skill_text.encode("utf-8")) |
|
messages = [{"role": "user", "content": task}] |
|
|
|
bar = "─" * 60 |
|
print(f"\n{bar}") |
|
print(f" SKILL AGENT") |
|
print(f"{bar}") |
|
print(f" Model : {model}") |
|
print(f" Work dir : {work_dir}") |
|
print(f" Task : {task}") |
|
print(f" Skill size : {skill_bytes:,} bytes (never truncated)") |
|
print(f" Chunk size : {chunk_size}") |
|
print(f" History window : {history_turns} turns") |
|
print(f" Max tool result : {max_tool_result} chars") |
|
print(f" Max payload : {max_payload_bytes} bytes") |
|
print(f"{bar}\n") |
|
|
|
for turn in range(1, MAX_TURNS + 1): |
|
active_history = prune_messages(messages, max_turns=history_turns) |
|
|
|
# ── Pre-flight payload size check ──────────────────────────────────── |
|
active_history = enforce_payload_limit( |
|
system_prompt, |
|
active_history, |
|
history_turns, |
|
max_payload_bytes, |
|
) |
|
|
|
response = client.chat( |
|
model=model, |
|
messages=[{"role": "system", "content": system_prompt}] + active_history, |
|
tools=TOOLS, |
|
) |
|
|
|
msg = response.message |
|
tool_calls = msg.tool_calls or [] |
|
messages.append(msg) |
|
|
|
# ── No tool calls → agent is done ─────────────────────────────────── |
|
if not tool_calls: |
|
print(f"\n{bar}") |
|
print(" COMPLETE") |
|
print(f"{bar}") |
|
print(msg.content or "(no summary returned)") |
|
print(f"{bar}\n") |
|
return |
|
|
|
# ── Execute tool calls ─────────────────────────────────────────────── |
|
for call in tool_calls: |
|
fn = call.function |
|
name = fn.name |
|
args = fn.arguments |
|
|
|
# Defensive: arguments are normally a dict in recent SDKs, |
|
# but fall back to json.loads for older servers / edge cases. |
|
if isinstance(args, str): |
|
try: |
|
args = json.loads(args) |
|
except json.JSONDecodeError: |
|
args = {} |
|
elif not isinstance(args, dict): |
|
args = {} |
|
|
|
# Pretty-print the call |
|
arg_str = ", ".join( |
|
f"{k}={repr(v)[:80]}" for k, v in args.items() |
|
) |
|
print(f" [{turn:02d}] ▶ {name}({arg_str})") |
|
|
|
result = execute_tool(name, args, work_dir, chunk_size) |
|
|
|
# ── Truncate result before storing in history ──────────────────── |
|
stored_result = truncate_tool_result(result, max_tool_result) |
|
if verbose: |
|
preview = result[:300].replace("\n", "\n ") |
|
print(f" ← {preview}") |
|
if len(result) > 300: |
|
print(f" … ({len(result)} chars total)") |
|
if len(result) > max_tool_result: |
|
print(f" [stored truncated to {max_tool_result} chars]") |
|
|
|
# Feed result back — Ollama expects role "tool" |
|
messages.append({ |
|
"role": "tool", |
|
"content": stored_result, |
|
}) |
|
|
|
print("[WARN] Maximum turn limit reached without completion.") |
|
|
|
|
|
# ── CLI ─────────────────────────────────────────────────────────────────────── |
|
|
|
def main() -> None: |
|
parser = ArgumentParser( |
|
description=__doc__, |
|
formatter_class=RawDescriptionHelpFormatter |
|
) |
|
parser.add_argument("--host", default=DEFAULT_HOST, help="Ollama server URL") |
|
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name") |
|
parser.add_argument("--api-key", required=True, help="API key (x-api-key header)") |
|
parser.add_argument("--skill", default=None, help="Path to SKILL.md") |
|
parser.add_argument("--work-dir", default=".", help="Working directory for file ops (default: cwd)") |
|
parser.add_argument("--task", default=None, help="Task description for the agent") |
|
parser.add_argument("--chunk-size", type=int, default=DEFAULT_CHUNK_LINES, help="Default lines per read_file chunk") |
|
parser.add_argument("--history-turns", type=int, default=DEFAULT_HISTORY_TURNS, help="Tool turns to retain in context window") |
|
parser.add_argument("--max-tool-result", type=int, default=DEFAULT_MAX_TOOL_RESULT, help="Max chars stored per tool result in history") |
|
parser.add_argument("--max-payload-bytes", type=int, default=DEFAULT_MAX_PAYLOAD_BYTES, help="Pre-flight serialized payload cap in bytes") |
|
parser.add_argument("--list-models", action="store_true", help="List available models and exit") |
|
parser.add_argument("--verbose", action="store_true", help="Print tool results in full") |
|
args = parser.parse_args() |
|
|
|
client = Client( |
|
host=args.host, |
|
headers={"Authorization": f"Bearer {args.api_key}"}, |
|
timeout=2400 |
|
) |
|
|
|
# ── List models mode ───────────────────────────────────────────────────── |
|
if args.list_models: |
|
models = client.list() |
|
print("Available models:") |
|
for m in models.models: |
|
print(f" • {m.model}") |
|
sys.exit(0) |
|
|
|
# ── Validate required args ─────────────────────────────────────────────── |
|
if not args.skill: |
|
parser.error("--skill is required unless --list-models is set") |
|
if not args.task: |
|
parser.error("--task is required unless --list-models is set") |
|
|
|
skill_path = Path(args.skill) |
|
if not skill_path.exists(): |
|
sys.exit(f"ERROR: SKILL file not found: {skill_path}") |
|
|
|
skill_text = skill_path.read_text(encoding="utf-8") |
|
|
|
work_dir = Path(args.work_dir).resolve() |
|
|
|
if not work_dir.is_dir(): |
|
sys.exit(f"ERROR: Working directory does not exist: {work_dir}") |
|
|
|
# Sync the tool schema default so the model knows what to expect. |
|
for tool in TOOLS: |
|
if tool["function"]["name"] == "read_file": |
|
tool["function"]["parameters"]["properties"]["limit"]["default"] = args.chunk_size |
|
|
|
run_agent( |
|
client = client, |
|
model = args.model, |
|
skill_text = skill_text, |
|
task = args.task, |
|
work_dir = work_dir, |
|
verbose = args.verbose, |
|
chunk_size = args.chunk_size, |
|
history_turns = args.history_turns, |
|
max_tool_result = args.max_tool_result, |
|
max_payload_bytes = args.max_payload_bytes, |
|
) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |