Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ ECA Agent Guide (AGENTS.md)
- Use `clojure.test` + `nubank/matcher-combinators`; keep tests deterministic.
- Put shared test helpers under `test/eca/test_helper.clj`.
- Use java class typing to avoid GraalVM reflection issues
- Avoid adding too many comments, only add essential or when you think is really important to mention something.
34 changes: 33 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,12 @@ interface ChatContentReceivedParams {
* The chat session identifier this content belongs to
*/
chatId: string;

/**
* If this chat is a subagent, the parent chat id.
* Useful for clients to associate subagent messages with the parent conversation.
*/
parentChatId?: string;

/**
* The content received from the LLM
Expand Down Expand Up @@ -1000,7 +1006,7 @@ interface ChatToolCallRejectedContent {

type ToolCallOrigin = 'mcp' | 'native';

type ToolCallDetails = FileChangeDetails | JsonOutputsDetails;
type ToolCallDetails = FileChangeDetails | JsonOutputsDetails | SubagentDetails;

interface FileChangeDetails {
type: 'fileChange';
Expand Down Expand Up @@ -1035,6 +1041,32 @@ interface JsonOutputsDetails {
jsons: string[];
}

interface SubagentDetails {
type: 'subagent';

/**
* The chatId of this running subagent, useful to link other chat/ContentReceived
* messages to this tool call.
* Available from toolCallRun afterwards
*/
subagentChatId?: string;

/**
* The model this subagent is using.
*/
model: string;

/**
* The max number of turns this subagent is limited.
*/
maxTurns: number;

/**
* The current turn.
*/
turn: number;
}

/**
* Extra information about a chat
*/
Expand Down
12 changes: 12 additions & 0 deletions resources/prompts/tools/spawn_agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Spawn a specialized agent to perform a focused task in isolated context.

The agent runs independently with its own conversation history and returns a summary of its findings/actions. Use this for:
- Codebase exploration without polluting your context
- Focused research on specific areas
- Delegating specialized tasks (review, analysis, etc.)

The spawned agent:
- Has access only to its configured tools
- Cannot spawn other agents (no nesting)
- Returns a summary when complete
- Does not share your conversation history
3 changes: 2 additions & 1 deletion src/eca/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@
"eca__directory_tree" {}
"eca__grep" {}
"eca__editor_diagnostics" {}
"eca__skill" {}}
"eca__skill" {}
"eca__spawn_agent" {}}
:ask {}
:deny {}}
:readFile {:maxLines 2000}
Expand Down
136 changes: 136 additions & 0 deletions src/eca/features/agents.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
(ns eca.features.agents
"Load and parse agent definitions for subagent spawning.

Agent definitions are Markdown files with YAML frontmatter.
They can be defined at:
- Project level: .eca/agents/*.md (highest priority)
- User level: ~/.config/eca/agents/*.md"
(:require
[babashka.fs :as fs]
[clojure.java.io :as io]
[clojure.string :as str]
[eca.config :as config]
[eca.shared :as shared]))

(set! *warn-on-reflection* true)

(defn ^:private parse-yaml-value
"Parses a simple YAML value, handling strings (quoted or unquoted), and basic types."
[s]
(let [trimmed (str/trim s)]
(cond
;; Empty or null
(or (empty? trimmed)
(= "null" (str/lower-case trimmed)))
nil

;; Double-quoted string
(and (str/starts-with? trimmed "\"") (str/ends-with? trimmed "\""))
(subs trimmed 1 (dec (count trimmed)))

;; Single-quoted string
(and (str/starts-with? trimmed "'") (str/ends-with? trimmed "'"))
(subs trimmed 1 (dec (count trimmed)))

;; Number
(re-matches #"-?\d+" trimmed)
(parse-long trimmed)

;; Unquoted string
:else trimmed)))

(defn ^:private parse-yaml-list [lines]
(loop [remaining lines
items []]
(if (empty? remaining)
[items remaining]
(let [line (first remaining)
trimmed (str/trim line)]
(if (str/starts-with? trimmed "- ")
(recur (rest remaining)
(conj items (parse-yaml-value (subs trimmed 2))))
[items remaining])))))

(defn ^:private parse-frontmatter [lines]
(loop [remaining lines
result {}]
(if (empty? remaining)
result
(let [line (first remaining)]
(if-let [[_ k v] (re-matches #"^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)$" line)]
(let [key (keyword k)
value-str (str/trim v)]
(if (empty? value-str)
;; Empty value - might be followed by list items
(let [[list-items rest-lines] (parse-yaml-list (rest remaining))]
(if (seq list-items)
(recur rest-lines (assoc result key list-items))
(recur (rest remaining) result)))
;; Inline value
(recur (rest remaining) (assoc result key (parse-yaml-value value-str)))))
(recur (rest remaining) result))))))

(defn ^:private parse-md [md-file]
(let [content (slurp (str md-file))
lines (str/split-lines content)]
(if (and (seq lines)
(= "---" (str/trim (first lines))))
(let [after-opening (rest lines)
metadata-lines (take-while #(not= "---" (str/trim %)) after-opening)
body-lines (rest (drop-while #(not= "---" (str/trim %)) after-opening))
metadata (parse-frontmatter metadata-lines)]
(assoc metadata :content (str/trim (str/join "\n" body-lines))))
{:content content})))

(defn ^:private agent-file->agent [agent-file]
(let [{:keys [name description tools model maxTurns content]} (parse-md agent-file)]
(when (and name description)
{:name name
:description description
:tools (or tools [])
:model model
:max-turns (or maxTurns 25)
:content content
:source (str (fs/canonicalize agent-file))})))

(defn global-agents-dir ^java.io.File []
(let [xdg-config-home (or (config/get-env "XDG_CONFIG_HOME")
(io/file (config/get-property "user.home") ".config"))]
(io/file xdg-config-home "eca" "agents")))

(defn ^:private global-agents
[]
(let [agents-dir (global-agents-dir)]
(when (fs/exists? agents-dir)
(keep agent-file->agent
(fs/glob agents-dir "*.md" {:follow-links true})))))

(defn ^:private local-agents
[roots]
(->> roots
(mapcat (fn [{:keys [uri]}]
(let [agents-dir (fs/file (shared/uri->filename uri) ".eca" "agents")]
(when (fs/exists? agents-dir)
(fs/glob agents-dir "*.md" {:follow-links true})))))
(keep agent-file->agent)))

(defn all
"Returns all available agent definitions.
Priority: local > global (later definitions override earlier ones by name)."
[config roots]
(let [agents-list (concat (when-not (:pureConfig config)
(global-agents))
(local-agents roots))]
(->> agents-list
(reduce (fn [m agent]
(assoc m (:name agent) agent))
{})
vals
vec)))

(defn get-agent
"Get a specific agent definition by name.
Returns nil if not found."
[agent-name config roots]
(let [agents (all config roots)]
(first (filter #(= agent-name (:name %)) agents))))
Loading
Loading