Files
tai/taicpp/src/agent/tools_ops.cpp
T

683 lines
32 KiB
C++
Raw Normal View History

#include "agent.h"
#include "../llm/helpers.h"
#include "../llm/proc.h"
#include "../llm/py.h"
#include "../llm/term.h"
#include "consts.h"
#include "text.h"
#include "zipfile.h"
#include <algorithm>
#include <cmath>
#include <ctime>
#include <filesystem>
#include <iostream>
#include <regex>
#include <unistd.h>
namespace fs = std::filesystem;
namespace detz::agent {
using llm::Ansi;
using llm::BackendError;
using llm::env_get;
using llm::paint;
using llm::short_error;
namespace {
UiPort* ui() { return ports().ui; }
std::string sv(const json::Value& v) { return py::str(v); }
// int(args.get(key) or dflt) -> nullopt on TypeError/ValueError
std::optional<int64_t> int_or(const json::Value& args, const char* key, int64_t dflt) {
json::Value v = args.get(key).truthy() ? args.get(key) : json::Value(dflt);
return py::to_int(v);
}
const char* kNoPlexer = "error: no plexer session active (neither tai's native wrapper nor tmux)";
} // namespace
// ------------------------------------------------------------------ terminals
std::string Tools::run_terminal_content(const json::Value& args) {
int lines = 100;
{
auto v = int_or(args, "lines", 100);
if (v.has_value()) { lines = static_cast<int>(std::max<int64_t>(10, std::min<int64_t>(500, *v))); }
}
std::string target = py::head(py::strip(py::str_or_empty(args.get("target"))), 128);
std::string history = shell_history_section();
if (target.empty()) {
std::string socket_path = env_get(PLEXER_SOCKET_ENV);
if (!socket_path.empty()) { return run_terminal_content_native(socket_path, lines, history); }
target = py::head(py::strip(env_get("TMUX_PANE")), 128);
}
if (llm::which("tmux").empty()) { return "tmux not available"; }
std::vector<std::string> header;
if (!target.empty()) {
header = {"pane " + target};
} else {
std::string ph = tmux_pane_header();
if (!ph.empty()) { header = {ph}; }
}
std::vector<std::string> argv = {"tmux", "capture-pane", "-p", "-S", "-" + std::to_string(lines)};
if (!target.empty()) { argv.push_back("-t"); argv.push_back(target); }
llm::ProcResult done = llm::run_process(argv, std::nullopt, 10);
if (done.spawn_failed) { return "terminal capture failed: " + llm::short_error_text(llm::oserror_text(done, "tmux")) + history; }
if (done.timed_out) { return "terminal capture failed: " + llm::short_error_text(llm::timeout_text(argv, 10)) + history; }
std::string out = py::universal_newlines(py::utf8_replace(done.out));
if (done.returncode != 0) {
return "terminal capture failed: " + py::head(py::strip(py::universal_newlines(py::utf8_replace(done.err))), 200) + history;
}
if (py::strip(out).empty()) { return "terminal pane is empty" + history; }
header.push_back(llm::truncate(out, 6000, 2000));
return py::join(header, "\n") + history;
}
std::string Tools::run_terminal_content_native(const std::string& socket_path, int lines, const std::string& history) {
if (ports().plexer == nullptr) { throw PortNotWired("plexer"); }
std::string pane_env = env_get(PLEXER_PANE_ENV);
std::string content;
try {
content = ports().plexer->native_read_pane(socket_path, pane_env, lines);
} catch (const PlexerFailure& exc) {
return std::string("terminal capture failed: ") + exc.what() + history;
}
if (py::strip(content).empty()) { return "terminal pane is empty" + history; }
std::string pane = py::strip(pane_env);
std::string header = !pane.empty() ? "native pane " + pane : "native plexer pane";
return header + "\n" + llm::truncate(content, 6000, 2000) + history;
}
std::string Tools::run_list_terminals(const json::Value&) {
auto [panes, error] = tmux_panes();
if (!error.empty()) { return error; }
if (panes.empty()) { return "no open terminals"; }
std::set<std::string> sessions, windows;
for (const auto& p : panes) {
sessions.insert(py::split(p.ref, ":")[0]);
size_t dot = p.ref.rfind('.');
windows.insert(dot == std::string::npos ? p.ref : p.ref.substr(0, dot));
}
auto plural = [](size_t n) { return n == 1 ? "" : "s"; };
std::vector<std::string> lines = {std::to_string(sessions.size()) + " session" + plural(sessions.size()) + " \xC2\xB7 " +
std::to_string(windows.size()) + " window" + plural(windows.size()) + " \xC2\xB7 " +
std::to_string(panes.size()) + " pane" + plural(panes.size())};
for (size_t i = 0; i < panes.size() && i < static_cast<size_t>(TERMINAL_LIST_MAX_PANES); ++i) {
const auto& p = panes[i];
lines.push_back(p.ref + " " + p.id + " " + p.command + " " + p.path + (p.active ? " *" : ""));
}
if (panes.size() > static_cast<size_t>(TERMINAL_LIST_MAX_PANES)) {
lines.push_back("... +" + std::to_string(panes.size() - static_cast<size_t>(TERMINAL_LIST_MAX_PANES)) + " more");
}
return py::join(lines, "\n");
}
std::string Tools::run_tail_terminals(const json::Value& args) {
int lines = TAIL_TERMINALS_DEFAULT_LINES;
{
auto v = int_or(args, "lines", TAIL_TERMINALS_DEFAULT_LINES);
if (v.has_value()) { lines = static_cast<int>(std::max<int64_t>(1, std::min<int64_t>(TAIL_TERMINALS_MAX_LINES, *v))); }
}
auto [panes, error] = tmux_panes();
if (!error.empty()) { return error; }
if (panes.empty()) { return "no open terminals"; }
std::vector<std::string> blocks;
for (size_t i = 0; i < panes.size() && i < static_cast<size_t>(TAIL_TERMINALS_MAX_PANES); ++i) {
const auto& pane = panes[i];
std::vector<std::string> argv = {"tmux", "capture-pane", "-p", "-t", pane.id, "-S", "-" + std::to_string(lines)};
llm::ProcResult done = llm::run_process(argv, std::nullopt, 10);
std::string head = "=== " + pane.ref + " " + pane.id + " (" + pane.command + ") ===\n";
if (done.spawn_failed || done.timed_out) { blocks.push_back(head + "(capture failed)"); continue; }
std::string out = py::universal_newlines(py::utf8_replace(done.out));
if (done.returncode != 0 || py::strip(out).empty()) { blocks.push_back(head + "(empty)"); continue; }
blocks.push_back(head + py::head(py::strip(out), TAIL_PER_PANE_CHARS));
}
if (panes.size() > static_cast<size_t>(TAIL_TERMINALS_MAX_PANES)) {
blocks.push_back("... +" + std::to_string(panes.size() - static_cast<size_t>(TAIL_TERMINALS_MAX_PANES)) +
" more panes, capture one with get_current_terminal_content");
}
return llm::truncate(py::join(blocks, "\n\n"), 6000, 2000);
}
std::string Tools::run_export_terminal_log(const json::Value& args) {
if (env_get("TMUX").empty()) { return "error: tmux is not active (this tool only works inside a live tmux session)"; }
if (llm::which("tmux").empty()) { return "error: tmux not available"; }
json::Value raw_tags = args.get("tags").truthy() ? args.get("tags") : json::Value::array();
std::vector<std::string> extra_tags;
if (!raw_tags.is_array()) { return "error: tags must be a list of strings"; }
for (const auto& t : raw_tags.items()) {
if (!t.is_string()) { return "error: tags must be a list of strings"; }
extra_tags.push_back(t.str());
}
std::vector<std::string> argv = {"tmux", "capture-pane", "-p", "-S", "-"};
llm::ProcResult done = llm::run_process(argv, std::nullopt, 30);
if (done.spawn_failed) { return "error: " + llm::short_error_text(llm::oserror_text(done, "tmux")); }
if (done.timed_out) { return "error: " + llm::short_error_text(llm::timeout_text(argv, 30)); }
if (done.returncode != 0) {
return "error: tmux capture failed: " + py::head(py::strip(py::universal_newlines(py::utf8_replace(done.err))), 200);
}
std::string raw = py::universal_newlines(py::utf8_replace(done.out));
if (py::strip(raw).empty()) { return "terminal pane history is empty"; }
std::string profile = active_profile();
std::string pane_header = tmux_pane_header();
if (pane_header.empty()) { pane_header = "pane"; }
char stamp_buf[32];
{
time_t t = static_cast<time_t>(py::now());
std::tm tmv{};
gmtime_r(&t, &tmv);
std::strftime(stamp_buf, sizeof stamp_buf, "%Y%m%dT%H%M%SZ", &tmv);
}
std::string stamp = stamp_buf;
std::string slug;
{
// re.sub(r"[^A-Za-z0-9_.-]+", "-", pane_header).strip("-") or "pane"
bool pending = false;
for (unsigned char c : pane_header) {
bool keep = (c < 0x80) && (std::isalnum(c) || c == '_' || c == '.' || c == '-');
if (keep) { if (pending) { slug += '-'; pending = false; } slug += static_cast<char>(c); }
else { pending = true; }
}
if (pending) { slug += '-'; }
slug = py::strip_chars(slug, "-");
if (slug.empty()) { slug = "pane"; }
}
std::string folder = app.config.home + "/termlogs/" + profile;
std::string path = folder + "/" + slug + "-" + stamp + ".log";
{
std::error_code ec;
fs::create_directories(folder, ec);
if (ec) { return "error: " + py::head(os_error_message(ec.value(), folder), 300); }
std::string err;
if (!write_text_file(path, raw, err)) { return "error: " + py::head(err, 300); }
}
if (app.store == nullptr) {
return "captured " + pane_header + " to " + path + " (" + std::to_string(raw.size()) + " bytes); vault unavailable, not indexed for search";
}
std::string title = "tmux " + pane_header + " captured " + stamp;
std::vector<std::string> tags = {"termlog"};
tags.insert(tags.end(), extra_tags.begin(), extra_tags.end());
std::string record_id = app.store->add_record("termlog", title, raw, tags, profile);
auto chunks = chunk_lines(collapse_repeated_lines(raw));
app.store->add_chunks("record", record_id, chunks, profile);
return "captured " + pane_header + " to " + path + " (" + std::to_string(raw.size()) + " bytes), indexed as " + record_id + " in " +
std::to_string(chunks.size()) + " chunks -- use search (kind chunk) to answer questions about this terminal session";
}
// ------------------------------------------------------------------ plexer
std::string Tools::run_plexer_send(const json::Value& args) {
if (ports().plexer == nullptr) { throw PortNotWired("plexer"); }
auto controller = ports().plexer->detect();
if (!controller) { return kNoPlexer; }
std::string text = py::str_or_empty(args.get("text"));
bool enter = args.get("enter").truthy();
try {
controller->send_keys(text, enter);
} catch (const PlexerFailure& exc) {
return std::string("error: ") + exc.what();
}
return "sent " + std::to_string(py::len(text)) + " chars via " + controller->kind() + " backend" + (enter ? " + enter" : "");
}
std::string Tools::run_plexer_read(const json::Value& args) {
if (ports().plexer == nullptr) { throw PortNotWired("plexer"); }
auto controller = ports().plexer->detect();
if (!controller) { return kNoPlexer; }
int lines = 200;
{
auto v = int_or(args, "lines", 200);
if (v.has_value()) { lines = static_cast<int>(std::max<int64_t>(1, std::min<int64_t>(*v, 5000))); }
}
std::string content;
try {
content = controller->read_pane(lines);
} catch (const PlexerFailure& exc) {
return std::string("error: ") + exc.what();
}
return llm::truncate(content.empty() ? std::string("(empty)") : content, 6000, 2000);
}
std::string Tools::run_plexer_type(const json::Value& args) {
if (ports().plexer == nullptr) { throw PortNotWired("plexer"); }
auto controller = ports().plexer->detect();
if (!controller) { return kNoPlexer; }
std::string text = py::str_or_empty(args.get("text"));
if (text.empty()) { return "error: empty text"; }
double delay = 0.03;
{
json::Value d = args.get("delay").truthy() ? args.get("delay") : json::Value(0.03);
auto f = py::to_float(d);
if (f.has_value()) { delay = std::max(0.0, std::min(*f, 2.0)); }
}
bool enter = !(args.get("enter").is_bool() && !args.get("enter").as_bool());
try {
controller->type_text(text, delay, enter);
} catch (const PlexerFailure& exc) {
return std::string("error: ") + exc.what();
}
char buf[32];
std::snprintf(buf, sizeof buf, "%.2f", delay);
return "typed " + std::to_string(py::len(text)) + " chars via " + controller->kind() + " backend at " + buf + "s/char";
}
std::string Tools::run_tui(const std::string& op, const json::Value& args) {
if (ports().tui == nullptr) { throw PortNotWired("tui"); }
json::Value payload = json::Value::object();
payload.set("op", op);
for (const auto& kv : args.members()) {
if (!kv.second.is_null()) { payload.set(kv.first, kv.second); }
}
if (op == "screen" && args.get("until").truthy()) {
json::Value t = args.get("timeout").truthy() ? args.get("timeout") : json::Value(GRR_CONTROL_WAIT);
if (t.is_int() || t.is_bool()) { payload.set("wait", t.as_int() + 2); }
else if (t.is_double()) { payload.set("wait", t.as_double() + 2); }
else if (t.is_string()) { throw std::runtime_error("can only concatenate str (not \"int\") to str"); }
else if (t.is_array()) { throw std::runtime_error("can only concatenate list (not \"int\") to list"); }
else { throw std::runtime_error("unsupported operand type(s) for +: '" + py_type_name(t) + "' and 'int'"); }
}
return ports().tui->control_call(payload).dumps();
}
std::string Tools::run_plexer_status(const json::Value&) {
if (ports().plexer == nullptr) { throw PortNotWired("plexer"); }
auto controller = ports().plexer->detect();
if (!controller) { return "no plexer session active (neither tai's native wrapper nor tmux)"; }
try {
return controller->status().dumps();
} catch (const PlexerFailure& exc) {
return std::string("error: ") + exc.what();
}
}
std::string Tools::run_plexer_show(const json::Value& args) {
const json::Value& spec = args.get("spec");
if (!spec.is_object()) { return "error: spec must be a JSON object (the flow contract is uiflow.md)"; }
if (ports().plexer == nullptr) { throw PortNotWired("plexer"); }
auto [flow_app, error] = ports().plexer->validate_flow_spec(spec);
if (!error.empty()) { return "error: invalid flow spec: " + error; }
if (!args.get("timeout").is_null()) {
auto override_v = py::to_int(args.get("timeout"));
if (!override_v.has_value()) { return "error: timeout must be an integer number of seconds"; }
if (!(PLEXER_FLOW_TIMEOUT_MIN <= *override_v && *override_v <= PLEXER_FLOW_TIMEOUT_MAX)) {
return "error: timeout must be " + std::to_string(PLEXER_FLOW_TIMEOUT_MIN) + " to " + std::to_string(PLEXER_FLOW_TIMEOUT_MAX) + " seconds";
}
flow_app.set("timeout", *override_v);
}
if (app.auto_mode) { return "plexer_show unavailable in autonomous mode: decide yourself and state the assumption"; }
if (!app.persist || app.depth > 0) {
return "plexer_show unavailable to background workers: decide yourself and state the assumption";
}
auto controller = ports().plexer->detect();
if (!controller) { return kNoPlexer; }
json::Value outcome;
try {
outcome = ports().plexer->run_flow(flow_app, *controller);
} catch (const PlexerFailure& exc) {
return std::string("error: ") + exc.what();
}
app.user_stepped_in = true;
return outcome.dumps();
}
// ------------------------------------------------------------------ agents
std::string Tools::run_fork(const json::Value& args) {
std::string task = py::strip(py::str_or_empty(args.get("task")));
if (task.empty()) { return "error: empty task"; }
if (app.depth >= FORK_MAX_DEPTH) { return "error: fork depth limit reached"; }
int64_t timeout = 600;
{
auto v = int_or(args, "timeout", 600);
if (v.has_value()) { timeout = std::max<int64_t>(30, std::min<int64_t>(3600, *v)); }
}
std::string asked = py::strip(py::str_or_empty(args.get("profile")));
if (!asked.empty() && !profile_ok(asked)) { return "error: invalid profile name"; }
if (!asked.empty() && asked != active_profile()) { return "error: cannot fork as another profile"; }
std::string profile = active_profile();
int agent_id;
try {
agent_id = spawn_agent(task, profile, static_cast<double>(timeout), app.config, app.depth + 1, app.runner_override);
} catch (const AgentsFull& exc) {
return std::string("error: ") + exc.what() + ", poll existing agents first";
}
return "agent " + std::to_string(agent_id) + " started: " + py::head(task, 80) + ". Collect its result with poll.";
}
std::string Tools::run_poll(const json::Value& args) {
auto id = int_or(args, "id", 0);
if (!id.has_value()) { return "error: invalid agent id"; }
int agent_id = static_cast<int>(*id);
int64_t wait = 0;
{
auto v = int_or(args, "wait", 0);
wait = v.has_value() ? std::max<int64_t>(0, std::min<int64_t>(120, *v)) : 0;
}
auto [status, text] = poll_agent(agent_id, 0, active_profile());
if (status == "running" && wait > 0) {
std::unique_ptr<LaneBoardPort> board;
if (ports().shell != nullptr) { board = ports().shell->make_lane_board(active_profile()); }
if (board) { board->start(); }
try {
std::tie(status, text) = poll_agent(agent_id, static_cast<double>(wait), active_profile());
} catch (...) {
if (board) { board->stop(); board_shown = true; }
throw;
}
if (board) { board->stop(); board_shown = true; }
}
if (status == "done" || status == "timeout" || status == "error") {
std::lock_guard<std::mutex> guard(AgentRegistry::lock());
auto it = AgentRegistry::records().find(agent_id);
if (it != AgentRegistry::records().end()) { it->second->collected = true; }
}
if ((status == "done" || status == "timeout") && app.store != nullptr) {
std::string full, task_text;
std::optional<std::string> spilled;
{
std::lock_guard<std::mutex> guard(AgentRegistry::lock());
auto it = AgentRegistry::records().find(agent_id);
if (it != AgentRegistry::records().end()) {
full = it->second->result;
task_text = it->second->task;
spilled = it->second->spilled;
}
}
if (static_cast<int>(py::len(full)) > SPILL_LIMIT) {
if (!spilled.has_value() || spilled->empty()) {
spilled = app.store->add_record("output", "agent " + std::to_string(agent_id) + ": " + py::head(task_text, 80), full,
{"agent-output"}, active_profile());
app.store->add_chunks("record", *spilled, chunk_lines(full), active_profile());
std::lock_guard<std::mutex> guard(AgentRegistry::lock());
auto it = AgentRegistry::records().find(agent_id);
if (it != AgentRegistry::records().end()) { it->second->spilled = spilled; }
}
return "[" + status + "] " + spilled_view(full, *spilled, py::len(full));
}
}
return "[" + status + "] " + text;
}
std::string Tools::run_ask_user(const json::Value& args) {
std::string question = py::strip(py::str_or_empty(args.get("question")));
if (question.empty()) { return "error: question must be a non-empty string"; }
json::Value raw_options = args.get("options").truthy() ? args.get("options") : json::Value::array();
if (!raw_options.is_array()) { return "error: options must be a list of strings"; }
std::vector<std::string> options;
{
size_t n = 0;
for (const auto& item : raw_options.items()) {
if (n++ >= static_cast<size_t>(ASK_USER_MAX_OPTIONS)) { break; }
json::Value label;
if (item.is_object()) {
label = item.has("label") ? item.get("label") : (item.has("value") ? item.get("value") : json::Value(""));
} else {
label = item;
}
std::string text = py::replace(py::strip(py::str_or_empty(label)), "\n", " ");
if (!text.empty()) { options.push_back(py::head(text, 200)); }
}
}
if (app.auto_mode) { return "ask_user unavailable in autonomous mode: decide yourself and state the assumption"; }
if (!app.persist || app.depth > 0) {
return "ask_user unavailable to background workers: decide yourself and state the assumption";
}
std::string raw;
if (app.prompter) {
auto got = app.prompter("ask", question, options);
if (!got.has_value()) { return "user gave no answer (input closed): proceed with your best assumption"; }
raw = *got;
} else {
if (!llm::stdin_isatty()) {
return "error: no interactive user on this stdin, decide yourself and state the assumption";
}
llm::print_out(paint("?- " + question, Ansi::CYAN, Ansi::BOLD));
for (size_t pos = 0; pos < options.size(); ++pos) {
llm::print_out(paint(" " + std::to_string(pos + 1) + ") " + options[pos], Ansi::DIM));
}
std::string prompt = options.empty()
? paint("answer (free input, empty skips) ", Ansi::YELLOW)
: paint("answer [1-" + std::to_string(options.size()) + ", option text, or free input] ", Ansi::YELLOW);
std::fputs(prompt.c_str(), stdout);
std::fflush(stdout);
if (!std::getline(std::cin, raw)) {
return "user gave no answer (input closed): proceed with your best assumption";
}
}
app.user_stepped_in = true;
std::string answer = py::strip(raw);
if (answer.empty()) { return "user gave no answer (empty): proceed with your best assumption"; }
if (static_cast<int>(py::len(answer)) > ASK_USER_MAX_ANSWER) { answer = py::head(answer, ASK_USER_MAX_ANSWER) + "...[truncated]"; }
if (!options.empty()) {
if (py::isdigit_ascii(answer)) {
auto pos = py::parse_int(answer);
if (pos.has_value() && 1 <= *pos && *pos <= static_cast<int64_t>(options.size())) {
return "user chose option " + std::to_string(*pos) + ": " + options[static_cast<size_t>(*pos - 1)];
}
}
std::string lowered = py::lower(answer);
for (size_t pos = 0; pos < options.size(); ++pos) {
if (py::lower(options[pos]) == lowered) {
return "user chose option " + std::to_string(pos + 1) + ": " + options[pos];
}
}
}
return "user answered: " + answer;
}
std::string Tools::run_sysinfo(const json::Value& args) {
if (ports().ops == nullptr) { throw PortNotWired("ops"); }
const json::Value& names = args.get("checks");
if (names.is_null()) { return ports().ops->collect_sysinfo(std::nullopt); }
if (!names.is_array() || names.size() == 0) { return "error: checks must be a non-empty list"; }
std::vector<std::string> valid = ports().ops->sysinfo_check_names();
std::vector<std::string> unknown, picked;
for (const auto& item : names.items()) {
std::string s = sv(item);
picked.push_back(s);
if (std::find(valid.begin(), valid.end(), s) == valid.end()) { unknown.push_back(s); }
}
if (!unknown.empty()) {
return "error: unknown checks: " + py::join(unknown, ", ") + " (valid: " + py::join(valid, ", ") + ")";
}
return ports().ops->collect_sysinfo(picked);
}
// ------------------------------------------------------------------ skills
std::string Tools::run_create_skill(const json::Value& args) {
std::string name = py::strip(py::str_or_empty(args.get("name")));
std::string brief = py::strip(py::str_or_empty(args.get("brief")));
if (name.empty() || !skill_name_ok(name)) { return "error: invalid skill name, use lowercase letters, digits, and hyphens"; }
if (brief.empty()) { return "error: empty brief"; }
if (app.depth >= FORK_MAX_DEPTH) { return "error: create_skill depth limit reached"; }
std::string scope = py::lower(py::strip(args.get("scope").truthy() ? py::str(args.get("scope")) : "project"));
if (scope != "project" && scope != "home") { return "error: scope must be project or home"; }
int64_t timeout = 1200;
{
auto v = int_or(args, "timeout", 1200);
if (v.has_value()) { timeout = std::max<int64_t>(60, std::min<int64_t>(3600, *v)); }
}
if (ports().skills == nullptr) { throw PortNotWired("skills"); }
std::string cwd = fs::current_path().string();
std::string skill_dir = ports().skills->skill_scope_dir(scope, app.config.home, cwd, name);
std::string skill_file = skill_dir + "/SKILL.md";
bool existed = isfile(skill_file);
if (ui() != nullptr) { ui()->spinner_start("creating skill " + name); }
std::string result;
try {
if (!app.runner_override) {
result = default_runner(app.config, app.profile, app.depth + 1, create_skill_prompt(name, brief, skill_dir),
static_cast<double>(timeout), CREATE_SKILL_STEPS);
} else {
result = app.runner_override(create_skill_prompt(name, brief, skill_dir), app.profile, static_cast<double>(timeout));
}
} catch (...) {
if (ui() != nullptr) { ui()->spinner_stop(); }
throw;
}
if (ui() != nullptr) { ui()->spinner_stop(); }
if (isfile(skill_file)) {
app.skills = ports().skills->discover_skills(app.config.home, cwd);
app.apply_system();
std::string action = existed ? "replaced" : "created";
return "skill '" + name + "' " + action + " at " + skill_file + "\n" + llm::truncate(result, 2000, 500);
}
std::string status = result.find("[time limit reached]") != std::string::npos ? "timeout" : "done";
return "skill '" + name + "' was not created (" + status + "), worker output:\n" + llm::truncate(result, 3000, 1000);
}
std::optional<std::string> Tools::find_skill_root(const std::string& base) {
if (isfile(base + "/SKILL.md")) { return base; }
std::vector<std::string> entries;
std::error_code ec;
for (const auto& e : fs::directory_iterator(base, ec)) { entries.push_back(e.path().filename().string()); }
if (ec) { return std::nullopt; }
std::sort(entries.begin(), entries.end());
std::vector<std::string> candidates;
for (const auto& e : entries) { if (isfile(base + "/" + e + "/SKILL.md")) { candidates.push_back(e); } }
if (candidates.size() == 1) { return base + "/" + candidates[0]; }
return std::nullopt;
}
std::pair<std::optional<std::pair<std::string, bool>>, std::string> Tools::resolve_skill_source(const std::string& source,
const std::string& workdir) {
if (py::endswith(source, ".git") || py::startswith(source, "git@") || py::startswith(source, "git+")) {
std::string git_url = py::startswith(source, "git+") ? source.substr(4) : source;
if (llm::which("git").empty()) { return {std::nullopt, "error: git is not installed, supply a local path or .zip instead"}; }
std::string clone_dir = workdir + "/clone";
llm::ProcResult done = llm::run_process({"git", "clone", "--depth", "1", git_url, clone_dir}, std::nullopt, 120);
if (done.spawn_failed) { return {std::nullopt, "error: git clone failed: " + llm::short_error_text(llm::oserror_text(done, "git"))}; }
if (done.timed_out) {
return {std::nullopt, "error: git clone failed: " + llm::short_error_text(llm::timeout_text({"git", "clone", "--depth", "1", git_url, clone_dir}, 120))};
}
if (done.returncode != 0) {
return {std::nullopt, "error: git clone failed: " + py::head(py::strip(py::universal_newlines(py::utf8_replace(done.err))), 300)};
}
auto found = find_skill_root(clone_dir);
if (!found.has_value()) { return {std::nullopt, "error: no SKILL.md found in " + git_url}; }
return {std::make_pair(*found, true), ""};
}
std::string path = expanduser(source);
if (!path_exists(path)) { return {std::nullopt, "error: no such file or directory: " + source}; }
if (isfile(path) && py::endswith(path, ".zip")) {
std::string extract_dir = workdir + "/zip";
try {
zip_extractall(path, extract_dir);
} catch (const BadZipFile& exc) {
return {std::nullopt, "error: bad zip file: " + short_error(exc)};
} catch (const std::runtime_error& exc) {
std::string what = exc.what();
if (what.rfind("[Errno", 0) == 0) { return {std::nullopt, "error: bad zip file: " + py::head(what, 300)}; }
throw;
}
auto found = find_skill_root(extract_dir);
if (!found.has_value()) { return {std::nullopt, "error: no SKILL.md found in " + source}; }
return {std::make_pair(*found, true), ""};
}
if (isfile(path) && py::endswith(path, ".md")) { return {std::make_pair(path, false), ""}; }
if (isdir(path)) {
auto found = find_skill_root(path);
if (!found.has_value()) { return {std::nullopt, "error: no SKILL.md found under " + source}; }
return {std::make_pair(*found, false), ""};
}
return {std::nullopt, "error: source must be a directory, a SKILL.md file, a .zip, or a git URL"};
}
namespace {
struct TempWorkdir {
std::string path;
TempWorkdir() {
std::string tmpl = (fs::temp_directory_path() / "tai-skill-XXXXXX").string();
std::vector<char> buf(tmpl.begin(), tmpl.end());
buf.push_back('\0');
if (mkdtemp(buf.data()) != nullptr) { path = buf.data(); }
}
~TempWorkdir() { std::error_code ec; if (!path.empty()) { fs::remove_all(path, ec); } }
};
} // namespace
std::string Tools::run_install_skill(const json::Value& args) {
std::string source = py::strip(py::str_or_empty(args.get("source")));
if (source.empty()) { return "error: empty source"; }
if (ports().skills == nullptr) { throw PortNotWired("skills"); }
SkillsPort& sk = *ports().skills;
std::string cwd = fs::current_path().string();
std::string scope = py::lower(py::strip(py::str_or_empty(args.get("scope"))));
if (scope.empty()) { scope = sk.default_skill_scope(cwd); }
if (scope != "project" && scope != "home") { return "error: scope must be project or home"; }
std::string mode_s = py::lower(py::strip(py::str_or_empty(args.get("mode"))));
std::optional<std::string> mode;
if (!mode_s.empty()) { mode = mode_s; }
if (mode.has_value() && *mode != "copy" && *mode != "symlink") { return "error: mode must be copy or symlink"; }
bool force = args.get("force").truthy();
TempWorkdir work;
if (ui() != nullptr) { ui()->spinner_start("installing skill from " + source); }
std::pair<std::optional<std::pair<std::string, bool>>, std::string> resolved_failure;
try {
resolved_failure = resolve_skill_source(source, work.path);
} catch (...) {
if (ui() != nullptr) { ui()->spinner_stop(); }
throw;
}
if (ui() != nullptr) { ui()->spinner_stop(); }
if (!resolved_failure.second.empty()) { return resolved_failure.second; }
std::string real_source = resolved_failure.first->first;
bool ephemeral = resolved_failure.first->second;
std::optional<std::string> source_dir;
if (isdir(real_source)) { source_dir = real_source; }
std::string skill_file = source_dir.has_value() ? real_source + "/SKILL.md" : real_source;
json::Value skill;
try {
skill = sk.parse_skill_file(skill_file);
} catch (const std::exception& exc) {
return "error: " + short_error(exc);
}
if (skill.is_null()) {
return "error: " + source + " has no valid SKILL.md (needs name, description, and a name of lowercase letters, digits, hyphens)";
}
std::string name = py::strip(py::str_or_empty(args.get("name")));
if (name.empty()) { name = py::str(skill.get("name")); }
if (!skill_name_ok(name)) { return "error: invalid skill name, use lowercase letters, digits, and hyphens"; }
if (!mode.has_value()) {
mode = (source_dir.has_value() && !ephemeral && sk.is_external_skill_root(*source_dir)) ? "symlink" : "copy";
} else if (*mode == "symlink" && (ephemeral || !source_dir.has_value())) {
return "error: symlink mode needs a persistent source directory, not a git/zip source or a bare SKILL.md file";
}
std::string scope_dir = sk.skill_scope_dir(scope, app.config.home, cwd, "");
std::string dest = scope_dir + "/" + name;
std::error_code ec;
bool existed = fs::is_symlink(dest, ec) || fs::exists(dest, ec);
if (existed && !force && !app.ask_approval("overwrite existing skill '" + name + "' at " + dest)) { return "denied by user"; }
if (existed) {
if (fs::is_symlink(dest, ec) || fs::is_regular_file(dest, ec)) { fs::remove(dest, ec); }
else { fs::remove_all(dest, ec); }
}
fs::create_directories(scope_dir, ec);
if (*mode == "symlink") {
fs::create_symlink(abspath(*source_dir), dest, ec);
} else if (source_dir.has_value()) {
fs::copy(*source_dir, dest, fs::copy_options::recursive, ec);
} else {
fs::create_directories(dest, ec);
fs::copy_file(real_source, dest + "/SKILL.md", ec);
}
if (ec) { throw std::runtime_error(os_error_message(ec.value(), dest)); }
json::Value meta = json::Value::object();
meta.set("source", source);
meta.set("mode", *mode);
meta.set("scope", scope);
meta.set("installed_at", py::utc_now_iso());
sk.save_skill_install_meta(scope_dir, name, meta);
if (app.store != nullptr) {
try {
app.store->audit_event(audit_actor(), "skill-install", dest,
"installed skill '" + name + "' from " + source + " (" + *mode + ", " + scope + " scope)",
std::nullopt, std::nullopt, {"skill", "install"}, std::nullopt, std::nullopt, "");
} catch (const std::exception&) {
}
}
app.skills = sk.discover_skills(app.config.home, cwd);
app.apply_system();
auto extras = sk.skill_extra_files(dest);
std::string tail = extras.empty() ? "" : "\nbundled files: " + py::join(extras, ", ");
std::string replaced = existed ? " (replacing the previous install)" : "";
return "skill '" + name + "' installed (" + *mode + ", " + scope + " scope) at " + dest + " from " + source + replaced + "\n" +
py::str(skill.get("description")) + tail;
}
} // namespace detz::agent