v0.2.74 - Add skill tool policy: skills can whitelist tools via the tools tag, enforced in the schema and at runtime

This commit is contained in:
Didactyl User
2026-09-19 08:32:15 -04:00
parent ca11518d58
commit 3a81c34504
25 changed files with 2722 additions and 13 deletions
+1 -1
View File
@@ -122,7 +122,7 @@ RUN NOSTR_LIB=$(ls /build/nostr_core_lib/libnostr_core_*.a 2>/dev/null | head -1
src/tools/tool_nostr_query.c src/tools/tool_nostr_my_events.c src/tools/tool_nostr_identity.c src/tools/tool_nostr_social.c \ src/tools/tool_nostr_query.c src/tools/tool_nostr_my_events.c src/tools/tool_nostr_identity.c src/tools/tool_nostr_social.c \
src/tools/tool_nostr_relay.c src/tools/tool_nostr_dm.c src/tools/tool_admin.c \ src/tools/tool_nostr_relay.c src/tools/tool_nostr_dm.c src/tools/tool_admin.c \
src/tools/tool_task.c src/tools/tool_nostr_list.c src/tools/tool_nostr_block.c src/tools/tool_local.c \ src/tools/tool_task.c src/tools/tool_nostr_list.c src/tools/tool_nostr_block.c src/tools/tool_local.c \
src/tools/tool_skill.c src/tools/tool_nostr_post.c src/tools/tool_memory.c src/tools/tool_config.c src/tools/tool_cashu_wallet.c src/tools/tool_blossom.c src/tools/tool_signer_crypto.c src/trigger_manager.c \ src/tools/tool_skill.c src/tools/tool_nostr_post.c src/tools/tool_nostr_reply.c src/tools/tool_memory.c src/tools/tool_config.c src/tools/tool_cashu_wallet.c src/tools/tool_blossom.c src/tools/tool_signer_crypto.c src/tools/tool_swarm.c src/trigger_manager.c \
src/cashu_wallet.c src/nostr_block_list.c src/prompt_template.c src/http_api.c src/setup_wizard.c src/mongoose.c src/debug.c \ src/cashu_wallet.c src/nostr_block_list.c src/prompt_template.c src/http_api.c src/setup_wizard.c src/mongoose.c src/debug.c \
src/json_to_markdown.c src/context_roles.c src/context_format.c src/signer_health.c \ src/json_to_markdown.c src/context_roles.c src/context_format.c src/signer_health.c \
-o /build/didactyl_static \ -o /build/didactyl_static \
+2
View File
@@ -31,11 +31,13 @@ SRCS = \
$(SRC_DIR)/tools/tool_local.c \ $(SRC_DIR)/tools/tool_local.c \
$(SRC_DIR)/tools/tool_skill.c \ $(SRC_DIR)/tools/tool_skill.c \
$(SRC_DIR)/tools/tool_nostr_post.c \ $(SRC_DIR)/tools/tool_nostr_post.c \
$(SRC_DIR)/tools/tool_nostr_reply.c \
$(SRC_DIR)/tools/tool_memory.c \ $(SRC_DIR)/tools/tool_memory.c \
$(SRC_DIR)/tools/tool_config.c \ $(SRC_DIR)/tools/tool_config.c \
$(SRC_DIR)/tools/tool_cashu_wallet.c \ $(SRC_DIR)/tools/tool_cashu_wallet.c \
$(SRC_DIR)/tools/tool_blossom.c \ $(SRC_DIR)/tools/tool_blossom.c \
$(SRC_DIR)/tools/tool_signer_crypto.c \ $(SRC_DIR)/tools/tool_signer_crypto.c \
$(SRC_DIR)/tools/tool_swarm.c \
$(SRC_DIR)/trigger_manager.c \ $(SRC_DIR)/trigger_manager.c \
$(SRC_DIR)/prompt_template.c \ $(SRC_DIR)/prompt_template.c \
$(SRC_DIR)/http_api.c \ $(SRC_DIR)/http_api.c \
+2 -2
View File
@@ -54,11 +54,11 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e
Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers. Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers.
## Current Status — v0.2.73 ## Current Status — v0.2.74
**Active build — this project is barely working. Experiment at your own risk.** **Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.2.73 — Updated nostr_core_lib to v0.6.15: migrated signer selection from nostr_index to role+role_path, added full n_signer transport support (unix/tcp/serial/fds) in wizard, config, and CLI, new signer_crypto tool, and PoW support in nostr_post > Last release update: v0.2.74 — Add skill tool policy: skills can whitelist tools via the tools tag, enforced in the schema and at runtime
- Connects to configured relays with auto-reconnect and relay state transition logging - Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected - Publishes configured startup events per relay as each relay becomes connected
+28 -2
View File
@@ -58,6 +58,7 @@ Trigger fires (DM, cron, subscription, webhook, chain)
| Tool | Description | | Tool | Description |
|---|---| |---|---|
| `nostr_post` | Publish a Nostr event to connected relays | | `nostr_post` | Publish a Nostr event to connected relays |
| `nostr_reply` | Reply to a Nostr event (NIP-10). Fetches the parent to discover its author and thread root, then publishes a reply with the correct `e`/`p` tags so clients thread it and the parent author is notified |
| `nostr_delete` | Request deletion of one or more previously published events (NIP-09 kind 5) | | `nostr_delete` | Request deletion of one or more previously published events (NIP-09 kind 5) |
| `nostr_react` | React to a Nostr event with like/dislike/emoji (NIP-25 kind 7) | | `nostr_react` | React to a Nostr event with like/dislike/emoji (NIP-25 kind 7) |
| `nostr_query` | Query events from relays using a Nostr filter | | `nostr_query` | Query events from relays using a Nostr filter |
@@ -739,9 +740,34 @@ Tool access is gated at two levels:
| **WOT** | In admin's kind 3 contact list | None | Chat-only LLM | | **WOT** | In admin's kind 3 contact list | None | Chat-only LLM |
| **STRANGER** | Anyone else | None | Configurable static response | | **STRANGER** | Anyone else | None | Configurable static response |
### Skill Requirements (all triggers) ### Skill Tool Policy (all triggers)
Skills declare which tools they need via `requires_tool` tags (see [SKILLS.md — Requirements Tags](SKILLS.md#requirements-tags)). During execution, only the required and optional tools declared by the skill are exposed to the LLM. If a skill has no `requires_tool` tags, all available tools are exposed. A skill declares which tools it may use via a `tools` tag: a comma-separated
**allowlist** of tool names.
```json
["tools", "swarm_read,swarm_claim,swarm_contribute,nostr_post,nostr_query"]
```
During execution, only the named tools are exposed to the LLM, and any tool call
outside the list is refused at runtime (`tool not permitted by skill policy`).
| `tools` tag value | Result |
|-------------------|--------|
| absent | all tools exposed (backward compatible) |
| empty string | all tools exposed |
| `*` or `all` | all tools exposed (explicit wildcard) |
| `swarm_read,swarm_claim` | only those two tools |
| `swarm_read, bogus_tool` | only `swarm_read`; `bogus_tool` skipped and warned |
This is a **whitelist**, not a blacklist: a newly added tool is not granted to a
skill unless the skill names it. Unknown names are ignored (with a warning) so a
typo does not break the skill.
> **Note:** The `requires_tool` tag described in
> [SKILLS.md — Requirements Tags](SKILLS.md#requirements-tags) is a documented
> capability declaration for cross-app portability. It is **not** yet wired into
> runtime enforcement; the `tools` tag is the enforced policy.
--- ---
+238
View File
@@ -0,0 +1,238 @@
# Skill Tool Policy — Implementation Plan
> **Goal:** Let a skill define or limit which tools are exposed to the LLM when
> that skill runs. A skill's `tools` tag becomes an enforced allowlist instead
> of an ignored annotation.
## Problem
Skills already declare a `tools` tag, e.g. the swarm worker skill:
```json
["tools", "swarm_read,swarm_claim,swarm_contribute,nostr_post,nostr_query"]
```
But the trigger path ignores it. In [`agent_on_trigger()`](../src/agent.c:1833):
```c
char* tools_json = tools_build_openai_schema_json(&g_tools_ctx);
```
and [`tools_build_openai_schema_json()`](../src/tools/tools_schema.c:2574) returns
the **full 92-tool schema** regardless of the skill. So a worker skill that lists
five tools is actually offered all 92 — including `nostr_post`, which the LLM
used to hand-roll malformed swarm events instead of calling `swarm_claim`.
This is a known gap: [`plans/swarm.md`](swarm.md:256) — *"The stored trigger tool
policy is not enforced on this path."* The docs already promise the behavior:
[`docs/TOOLS.md`](../docs/TOOLS.md:745) and [`docs/CONTEXT.md`](../docs/CONTEXT.md:98).
## What already exists (reuse, do not rebuild)
The plumbing is mostly in place:
| Piece | Location | Status |
|-------|----------|--------|
| `tools_policy[256]` field on the trigger | [`active_trigger_t`](../src/trigger_manager.h:52) | Exists |
| Parse `tools` tag from skill | [`parse_trigger_runtime_tags()`](../src/trigger_manager.c:551) | Exists |
| Store policy on trigger | [`trigger_manager_add()`](../src/trigger_manager.c:1449) | Exists |
| Expose policy in trigger list JSON | [`trigger_manager.c`](../src/trigger_manager.c:1906) | Exists |
| **Apply policy to the schema** | — | **Missing** |
| **Pass policy into `agent_on_trigger()`** | [`execute_llm_action()`](../src/trigger_manager.c:921) | **Missing** |
So the work is: (1) a schema-filtering function, (2) thread the policy through
to the trigger execution, (3) enforce at execution time as defense in depth.
## Design
### Policy format
The `tools` tag is a comma-separated list of tool names:
```
swarm_read,swarm_claim,swarm_contribute,nostr_post,nostr_query
```
**This is a whitelist (allowlist).** The named tools are the *only* tools
exposed. There is no blacklist form in this design.
#### Wildcard
Support an explicit allow-all token so a skill can state its intent rather than
rely on omission:
- `*` (or the word `all`) → expose every tool. Equivalent to omitting the tag.
This matters because "no tag" and "tag = `*`" should mean the same thing, but
the explicit form is self-documenting and lets a skill author say "I deliberately
want everything" instead of leaving it ambiguous.
#### Semantics
| `tools` tag value | Result |
|-------------------|--------|
| absent | all tools (backward compatible) |
| empty string | all tools |
| `*` or `all` | all tools (explicit wildcard) |
| `swarm_read,swarm_claim` | only those two tools |
| `swarm_read, bogus_tool` | only `swarm_read`; `bogus_tool` skipped + warned |
- **Tag present, non-empty, not a wildcard** → allowlist. Only the named tools
are exposed.
- **Tag absent or empty** → all tools exposed (backward compatible; matches
[`docs/TOOLS.md`](../docs/TOOLS.md:745): *"If a skill has no `requires_tool`
tags, all available tools are exposed."*).
- **Unknown tool name in the list** → ignored (do not fail the skill). Log a
warning so typos are visible.
- **Whitespace** around names is trimmed.
#### Why whitelist, not blacklist
- **Safer default.** A new tool added to the codebase is *not* automatically
granted to every skill. With a blacklist, every new tool is silently exposed
everywhere until someone remembers to deny it.
- **Matches the existing docs.** [`docs/TOOLS.md`](../docs/TOOLS.md:745) already
frames this as "only the required and optional tools declared by the skill are
exposed" — a whitelist.
- **Least privilege.** The swarm bug is precisely a case where a worker should
*not* have had `nostr_post`. A whitelist expresses that directly.
A blacklist form (e.g. `-local_shell_exec`) is listed as a possible follow-up,
but is not part of this plan. If both are ever supported, the rule should be:
whitelist first, then subtract any blacklist entries.
### Optional: `requires_tool` alias
[`docs/SKILLS.md`](../docs/SKILLS.md:439) documents a repeatable
`["requires_tool", "<name>"]` tag. Support it as an alias for the same policy:
if a skill has one or more `requires_tool` tags, union them into the allowlist.
This is additive and low-risk. (Note: `find_tag_value_string()` returns only the
first match, so a new helper is needed to collect all `requires_tool` values.)
### Where filtering happens
Add a pure function in [`src/tools/tools_schema.c`](../src/tools/tools_schema.c:1):
```c
/* Return a new schema array containing only tools named in `policy_csv`.
* policy_csv == NULL, "" or "*"/"all" returns the full schema (all tools).
* Unknown names are skipped. Caller frees the returned string. */
char* tools_build_openai_schema_json_filtered(const tools_context_t* ctx,
const char* policy_csv);
```
Implementation: build the full schema, parse it, iterate the array, and drop any
entry whose `function.name` is not in the allowlist. Short-circuit to the full
schema when the policy is empty or a wildcard. This mirrors the existing
[`build_sandboxed_tools_json_local()`](../src/tools/tool_skill.c:718) pattern, so
the approach is already proven in the codebase.
A shared helper `tool_allowed_by_policy(name, policy_csv)` should back both the
schema filter and the execution guard, so the two can never disagree. It returns
1 when the policy is empty/wildcard, or when `name` is in the list.
### Threading the policy to execution
1. Change the signature of [`agent_on_trigger()`](../src/agent.c:1771) to accept
the policy:
```c
void agent_on_trigger(const char* skill_d_tag,
const char* skill_content,
const char* tools_policy, /* NEW */
cJSON* triggering_event,
const char* relay_url);
```
2. In [`execute_llm_action()`](../src/trigger_manager.c:921), pass
`t->tools_policy`:
```c
agent_on_trigger(t->skill_d_tag, t->skill_content, t->tools_policy, event, relay_url);
```
3. In `agent_on_trigger()`, replace the schema build:
```c
char* tools_json = tools_build_openai_schema_json_filtered(&g_tools_ctx, tools_policy);
```
4. Update the forward declaration in [`trigger_manager.c`](../src/trigger_manager.c:16)
and any other callers.
### Defense in depth: enforce at execution
Filtering the schema is the primary control, but the LLM could still emit a tool
call for a name not in the schema. Add a guard in the trigger tool loop
([`agent.c`](../src/agent.c:1910)) before `tools_execute()`:
```c
if (!tool_allowed_by_policy(tc->name, tools_policy)) {
tool_result = strdup("{\"success\":false,\"error\":\"tool not permitted by skill policy\"}");
} else {
tool_result = tools_execute(&g_tools_ctx, tc->name, tc->arguments_json);
}
```
This makes the policy a real boundary, not just a prompt hint — the same
principle [`plans/swarm.md`](swarm.md:258) calls for: *"runtime-enforced
per-execution permissions — not just prompt instructions or schema filtering."*
## Scope decisions
- **Trigger path only (first cut).** The DM path
([`agent.c`](../src/agent.c:2141)) and the HTTP API path
([`http_api.c`](../src/http_api.c:1007)) keep the full schema. DM already has
sender-tier gating; the API is admin-only. Filtering those is a follow-up.
- **Compose with `skill_run` sandbox.** [`execute_skill_run()`](../src/tools/tool_skill.c:2357)
already filters for external skills. Leave it; the new policy applies to the
trigger path. A later pass can intersect the two.
- **`tools_policy` is 256 bytes.** A long allowlist could truncate. Either raise
the buffer or validate length at parse time and warn. Note this in the change.
## Files to change
| File | Change |
|------|--------|
| [`src/tools/tools_schema.c`](../src/tools/tools_schema.c:2574) | Add `tools_build_openai_schema_json_filtered()` |
| [`src/tools/tools.h`](../src/tools/tools.h:30) | Declare the new function |
| [`src/agent.c`](../src/agent.c:1771) | `agent_on_trigger()` takes `tools_policy`; use filtered schema; add execution guard |
| [`src/trigger_manager.c`](../src/trigger_manager.c:16) | Update forward decl; pass `t->tools_policy` |
| [`src/trigger_manager.h`](../src/trigger_manager.h:52) | (optional) raise `tools_policy` size |
| [`docs/TOOLS.md`](../docs/TOOLS.md:745) | Correct the doc to describe the `tools` tag (not just `requires_tool`) |
## Verification
1. **Unit-ish:** `--dump-schemas` still lists all 92 (no policy). Add a debug
path or log line showing the filtered count for a trigger.
2. **Swarm test:** restart the workers, post a "my queen" question, and confirm
in the worker debug log that the LLM request's tool list contains only the
five swarm tools — no `nostr_post`.
3. **Behavioral:** confirm workers now call `swarm_claim` (not `nostr_post`) and
that claims carry `swarm-task` tags.
4. **Regression — no tag:** a skill with no `tools` tag still gets all tools.
5. **Wildcard:** a skill with `["tools", "*"]` gets all tools (same as no tag).
6. **Allowlist:** a skill with `["tools", "swarm_read,swarm_claim"]` gets exactly
those two.
7. **Unknown name:** `["tools", "swarm_read,bogus"]` gets `swarm_read` and logs a
warning for `bogus`.
8. **Execution guard:** a forced tool call for a non-allowlisted tool returns
`tool not permitted by skill policy` rather than executing.
## Risks
- **Over-restriction.** A skill that lists too few tools will fail tasks it
previously handled. Mitigation: the "no tag = all tools" fallback, plus a
clear error when a blocked tool is called.
- **Truncation.** The 256-byte `tools_policy` buffer. Mitigation: raise it or
validate.
- **Silent typos.** An unknown tool name is skipped, so a misspelled tool
silently disappears. Mitigation: log a warning listing unknown names.
## Follow-ups (out of scope)
- Apply policy to the DM and HTTP API paths.
- Intersect skill policy with the `skill_run` sandbox allowlist.
- Support a deny-list form (e.g. `-local_shell_exec`) if needed.
- Surface the effective tool set in `/api/status` or `trigger_list` for
observability.
+338
View File
@@ -0,0 +1,338 @@
# Didactyl Swarms — The Thread Model
> **Status:** design. A swarm is a Nostr thread. A queen seeds it, workers contribute to it, and anyone — agent or human — can take part.
## The Idea
A swarm is a **Nostr thread**.
Someone posts a problem as a kind 1 note. Agents and humans read the thread, post replies as they work, and follow each other to see progress. There is no server, no shared database, no required leader. The thread *is* the shared state, and relays *are* the message bus.
This is not a new subsystem bolted onto Didactyl. It is the existing trigger + skill + tool machinery pointed at a shared conversation. An agent participates the same way it responds to a DM: a trigger fires, the LLM reads context, it calls tools, it posts a reply.
```mermaid
flowchart TD
ROOT[Thread root<br/>kind 1 problem statement]
A[Agent A] -->|replies| ROOT
B[Agent B] -->|replies| ROOT
C[Human C] -->|replies| ROOT
ROOT -->|thread visible to all| A
ROOT -->|thread visible to all| B
ROOT -->|thread visible to all| C
```
## Design Principles
**Nostr ethics.** Decentralized, permissionless, censorship resistant, interoperable, sovereign. No indispensable authority, no central service, no gatekeeper. Any participant can leave; the thread continues.
**Workers are smart.** A worker is an agent that knows Nostr, can read a thread, can talk to peers, and can figure out what to do. The protocol should give it a shared medium and a few conventions — not a rigid state machine. We describe *how to talk*, not *what to think*.
**Keep it open-ended.** The thread is a conversation. Agents are free to invent contribution styles, negotiate, ask questions, and disagree. The conventions below are defaults, not laws.
## Event Conventions
No new event kinds. A swarm is defined by tags on ordinary kind 1 notes.
### Thread Root
The problem statement. Published by whoever wants the problem solved — often the admin.
```json
{
"kind": 1,
"content": "PROBLEM: Design a censorship-resistant static site deployment.\n\nGOAL: A site that survives the loss of any single jurisdiction.\n\nCONSTRAINTS: No single hosting provider. Must be reproducible from git.",
"tags": [
["t", "didactyl-swarm"],
["swarm", "root"],
["swarm-title", "Censorship-resistant site"]
]
}
```
| Tag | Meaning |
|-----|---------|
| `["t", "didactyl-swarm"]` | Global marker — any client can find swarm threads |
| `["swarm", "root"]` | Marks this note as a thread root |
| `["swarm-title", "..."]` | Human-readable title |
The root is **immutable**. It is never republished or edited. If the problem changes, post a new note in the thread that says so.
### Contributions
A contribution is a reply in the thread. It is an ordinary kind 1 note with a root reference:
```json
{
"kind": 1,
"content": "I will take the nginx + TLS layer. ETA one cycle.",
"tags": [
["e", "<root_event_id>", "<relay_hint>", "root"],
["e", "<parent_event_id>", "<relay_hint>", "reply"],
["t", "didactyl-swarm"],
["swarm-type", "claim"]
]
}
```
The `["e", root, "", "root"]` tag is what makes the whole swarm one thread. Any Nostr client renders it as a conversation.
`swarm-type` is a **light convention**, not an enforced enum. Suggested values:
| `swarm-type` | Meaning |
|--------------|---------|
| `claim` | "I am taking this" — an intention, not a lock |
| `progress` | "Here is where I am" |
| `result` | "Here is a finished artifact" |
| `question` | "I am blocked / need input" |
| `synthesis` | "Here is a combined answer" |
Agents may omit `swarm-type` or invent their own. A human replying from Damus will not add it at all — their reply is still a valid contribution, just untyped. Readers should treat unknown or missing types as ordinary conversation.
### Side Threads
A subproblem can get its own root, linked to the parent. This forms a tree of threads.
```json
{
"kind": 1,
"content": "PROBLEM: Which jurisdiction should host the first node?",
"tags": [
["t", "didactyl-swarm"],
["swarm", "root"],
["e", "<parent_root_event_id>", "<relay_hint>", "root"],
["swarm-title", "First node jurisdiction"]
]
}
```
The link to the parent is an ordinary `e` tag. Discovery of side threads uses standard event references — not a custom multi-character tag, which relays do not index. A side thread is still a `["swarm", "root"]`, so it is discoverable on its own, and it also points back at its parent.
```mermaid
flowchart TD
MAIN[Main thread<br/>Censorship-resistant site]
SUB1[Side thread<br/>First node jurisdiction]
SUB2[Side thread<br/>TLS automation]
MAIN --> SUB1
MAIN --> SUB2
SUB1 --> C1[contributors]
SUB2 --> C2[contributors]
```
Any contributor can start a side thread for a subproblem it identifies. When a side thread reaches a conclusion, someone posts a `result` on the parent that references it. This gives hierarchical decomposition without a central planner.
## Roles: Queen and Workers
A swarm benefits from a **queen** — an agent that watches the admin's posts and seeds swarms. The queen is a role, not a server. Any agent can play it, and the admin can run several queens for redundancy.
```mermaid
flowchart TD
ADMIN[Admin posts a problem<br/>kind 1 note]
QUEEN[Queen agent<br/>watches admin posts]
ADMIN -->|trigger: admin kind 1| QUEEN
QUEEN -->|reply with plan| ROOT[Thread root]
QUEEN -->|invite| SPEC[Specialist agents]
QUEEN -->|spawn| WORKER[New worker agents]
QUEEN -->|recruit| HUMAN[Humans on Nostr]
SPEC --> ROOT
WORKER --> ROOT
HUMAN --> ROOT
```
When the admin posts something that looks like a problem, the queen may:
1. Decide whether it is worth a swarm.
2. Reply to the admin's post with a proposed decomposition and plan.
3. Invite specialists it knows, and/or spawn new workers.
4. Optionally recruit humans on Nostr.
5. Keep contributing — reviewing progress, answering questions, synthesizing results.
**The queen has no authority.** It can propose, suggest, and ask. It cannot command. Other participants may decline, propose alternatives, or keep working if the queen disappears. Its only leverage is persuasion and the trust others place in it.
The admin's original post stays the canonical problem reference. The queen replies to it rather than replacing it, so multiple queens share a common anchor.
## Policy and Permission
A thread may declare a **policy** describing who it is for. But policy is not access control.
Anyone can publish a reply referencing a public event. A check in your own contribution tool only constrains your own implementation. So policy means:
- **What an agent considers relevant** — which threads it pays attention to.
- **What an agent considers trusted** — whose claims, results, and requests it acts on.
- **What an agent is willing to spend** — compute, wallet, files, credentials.
These are enforced **on receipt, before invoking the LLM or tools**, and again when taking any action. Public visibility and restricted execution are compatible: permissionless participation does not mean permissionless access to someone else's resources.
| Policy | Meaning |
|--------|---------|
| `open` | Anyone may contribute; treat all contributions as relevant |
| `wot` | Prefer contributions from identities you already trust |
| `roster` | Prefer contributions from a known participant list |
A `roster` is a set of signed membership statements, not a tag on the root — the root is immutable. Membership can be expressed as replies, or as a separately referenced addressable event. Private collaboration would need a separate encryption and membership design; a roster does not make public notes private.
## Trust, Following, and Vouching
These are four different things and should not be conflated:
| Concept | Meaning |
|---------|---------|
| **Thread subscription** | I want to receive this conversation |
| **Kind 3 follow** | I want an ongoing social/discovery relationship with this identity |
| **Endorsement** | I recommend this participant for this project |
| **Permission** | My operator permits specific actions using my resources |
**Following someone does not authorize their requests.** Spawning an agent establishes provenance, not competence, and does not grant unlimited inherited trust.
For siblings, pass a participant list and thread references during provisioning. They can collaborate immediately without publishing mutual follows. Persistent follows are optional — do not build an all-to-all follow graph just because agents share a task.
For project-specific trust, prefer signed endorsements that reference the project, participant, scope, and expiration. Define whether delegation is allowed and how it is revoked.
## Discovery
Kind 0 profiles are useful introductions. An agent can declare what it is good at:
```json
{
"kind": 0,
"content": "{\"name\":\"didactyl-nginx\",\"display_name\":\"Nginx Specialist\",\"about\":\"Didactyl agent specializing in web serving and TLS.\",\"specialty\":[\"nginx\",\"tls\",\"reverse-proxy\"],\"agent\":true}"
}
```
| Field | Meaning |
|-------|---------|
| `specialty` | Free-form tags describing what this agent is good at |
| `agent` | Marks this profile as an agent, not a human |
Relays cannot query arbitrary fields inside profile content. Discovery means fetching candidate profiles and matching locally. Optional search services are accelerators, not required infrastructure.
**Start socially, not globally.** Begin with known contacts, previous collaborators, thread participants, and explicit recommendations. Fetch their profiles, look at their work, then ask whether they are available. Specialties are self-declarations — not verified capabilities or current availability. Missing agent metadata does not prove an account is human.
Avoid raw reaction counts as a trust metric. One operator can create many identities, especially in a system designed to spawn them. Prefer evidence and endorsements from independently trusted sources.
## Recruiting Humans
Not every problem needs an agent. Some need a person. Because the thread is a plain kind 1 conversation, a human can contribute from any Nostr client — Damus, Amethyst, a web client. The swarm does not care whether a contributor is an agent or a human.
A queen can reach out to humans by searching profiles and sending a NIP-17 DM with the thread's event link. The human opens the thread and replies like any other note. Their reply is indistinguishable from an agent contribution.
Human outreach should be targeted, disclose that the requester is an agent, respect declines, and have invitation limits. Do not turn specialty search into automatic bulk messaging.
## Spawning
Spawning is a separate infrastructure problem. Creating an identity is not the same as launching a working agent.
A real spawn needs:
1. An authorized host or compute provider.
2. A distinct identity and securely provisioned signer access.
3. Minimal skills and project context — not a copy of all parent secrets and memory.
4. An isolated workspace, process, API port, credentials, and resource limits.
5. Readiness reporting, stop/restart behavior, and cleanup.
Note the circularity: an agent cannot be sent its private key by DM, because it needs that key or signer access before it can decrypt the message. Provisioning must happen out of band.
A **local supervisor** is a reasonable first implementation. It does not centralize the collaboration protocol — other operators can run their own supervisors on other hosts. Initially, launch a few agents manually and prove collaboration before automating provisioning.
## Runtime Requirements
The thread is a collaboration medium. It is not by itself a scheduler, a permission system, or a reliable task ledger. The engineering effort goes into how each sovereign agent safely receives, evaluates, and acts on the conversation.
### Reliable, bounded execution
The current trigger path is not a reliable work queue. [`maybe_fire_trigger_locked()`](src/trigger_manager.c:928) rejects events whose timestamp is equal to or older than the latest seen timestamp, so two workers posting in the same second can cause a valid contribution to be skipped. Cooldown returns without retaining work for later.
- Separate event ingestion from deciding when to think.
- Deduplicate by event ID, not timestamp.
- Mark a thread as having new activity and process bounded batches.
- Cooldown should defer reasoning, not discard contributions.
- Retain events and support replay.
### One identity per process
[`agent_on_trigger()`](src/agent.c:1771) runs a blocking LLM/tool loop and services relays between turns; the code documents the blocked main loop at [this point](src/agent.c:1869). Agent context and temporary model configuration use shared process state. Start with one identity per process and a serialized work scheduler. Do not run concurrent agent executions inside one process without isolating their contexts and configuration.
### Runtime-enforced permissions
The schema builder [`tools_build_openai_schema_json_legacy()`](src/tools/tools_schema.c:7) ignores its context, and the trigger runner calls [`tools_execute()`](src/tools/tools_dispatch.c:343), which routes directly to the legacy dispatcher. The stored trigger tool policy is not enforced on this path.
Before exposing a swarm trigger to public input, add **runtime-enforced per-execution permissions** — not just prompt instructions or schema filtering. Treat profiles, contributions, and linked artifacts as untrusted content.
### Feedback and cost
Do not make every public swarm note trigger every agent. Use explicit thread subscriptions, batching, bounded context, per-thread and per-agent budgets, self-event suppression, and a quiet/no-action outcome. Put spawning limits and cancellation in the runtime.
### Publishing is not durable replication
The [publish path](src/nostr_handler.c:3559) uses asynchronous publishing without an acknowledgement callback; its success return should not be read as durable replication. Track outbound events, retry the same signed event, and distinguish queued, relay-accepted, and peer-observed states.
## Coordination Without a Leader
The thread has no orchestrator, but it still coordinates. The mechanisms are emergent:
| Problem | Thread solution |
|---------|-----------------|
| Who does what? | `claim` notes — advisory intentions; others see them and pick something else |
| How do we know progress? | `progress` notes stream into the thread |
| How do we combine results? | `synthesis` notes — anyone may post one, not just the root author |
| What if an agent dies? | Its claims go stale; another agent picks up the work |
| What if two agents collide? | Both post results; competing syntheses are allowed |
| How is quality judged? | Evidence and endorsements from trusted sources |
| How does the admin steer? | The admin posts a contribution; agents treat admin notes as authoritative |
**An append-only conversation does not eliminate coordination conflicts.** Two agents can claim the same work, publish contradictory conclusions, or perform the same external action. Combining their notes does not resolve those conflicts, and author timestamps are not a trustworthy global lock.
For an initial version:
- Treat claims as advisory intentions, not exclusive ownership.
- Give subtasks stable references; attach results to the work they address.
- Distinguish "worker finished," "result reviewed," and "problem accepted as solved."
- Permit competing syntheses.
- Keep irreversible actions behind explicit authorization and idempotency safeguards.
## Failure and Partition Behavior
```mermaid
flowchart LR
subgraph Normal
N1[3 agents, 1 thread] --> N2[all see all contributions]
end
subgraph Partition
P1[relay split] --> P2[two partial views]
P2 --> P3[on heal, thread merges by event id]
end
subgraph AgentLoss
L1[agent dies] --> L2[claims go stale]
L2 --> L3[another agent picks up the work]
end
```
Because every contribution is an immutable signed event with a unique id, partitions heal by set union. There is no merge conflict in the *events* — though there may still be conflict in their *meaning*, which the participants resolve by talking.
Subscribing to a hashtag does not reveal every swarm everywhere. You see what your queried relays retain and serve. Relay hints, overlapping relay sets, backfill, and republishing are part of the design — not automatic properties of Nostr.
## Relationship to Prior Plans
| Plan | Relationship |
|------|--------------|
| [`plans/DECENTRALIZED_DIDACTYL.md`](DECENTRALIZED_DIDACTYL.md) | Broadcast-debounce is *redundancy* — N agents do the same task, pick one. The thread model is *collaboration* — N agents do different parts of one task. |
| [`plans/agent_clone.md`](agent_clone.md) | Cloning creates a new identity. Spawning reuses that machinery and adds provisioning and a project invitation. |
| [`plans/agent_tasks.md`](agent_tasks.md) | Agent tasks are *private* short-term memory. Swarm contributions are *public* shared state. An agent can mirror its swarm claims into its private task list. |
| [`plans/tool_orchestration.md`](tool_orchestration.md) | Hardened skills could later let a swarm run deterministic steps, but the thread model needs no orchestration layer. |
## Implementation Order
1. **Correct the protocol sketch** — immutable roots, ordinary replies, side-thread references, policy versus permission, completion semantics.
2. **Harden execution** — event-ID ingestion, deferred batches, replay, runtime tool permissions, budgets, outbound retry tracking.
3. **Prove one queen and two existing workers** — one public problem, separate identities, bounded research and drafting, a normal human reply, and a synthesized result.
4. **Test failure behavior** — duplicate and same-second events, delayed delivery, restart, relay loss, malicious contributions, queen disappearance, budget exhaustion.
5. **Add discovery and side threads** — known-contact profiles, optional endorsements, targeted invitations, result rollups.
6. **Add spawning** — isolated local provisioning first, then independently operated remote hosts.
The existing [`AgentProcess`](tests/harness/agent_process.py:16) harness is a useful starting point for multi-agent tests, with distinct configurations, ports, and logs.
## Open Questions
1. **Contribution typing** — how much structure helps without constraining smart workers? Recommendation: keep `swarm-type` optional and advisory.
2. **Claim staleness** — how long before a claim is considered abandoned? Recommendation: let workers judge from context; no hard rule.
3. **Endorsement format** — what does a project-scoped endorsement look like, and how is it revoked? Recommendation: defer until discovery is needed.
4. **Cost control** — how do agents avoid over-participating in busy threads? Recommendation: per-thread and per-agent budgets in the runtime, plus a quiet outcome.
+415
View File
@@ -0,0 +1,415 @@
# Swarm Proof of Concept
> **Goal:** prove the thread model works end to end with a queen and a few workers, all on a single local relay.
## Status: Working end to end
The PoC is running. Three agents — a queen and two workers — are live on `ws://127.0.0.1:7777`.
**T1 passes.** The swarm decomposed a problem, divided the work, computed all
five subtasks correctly, and synthesized the exact right answer.
**What works:**
- The queen wakes on the phrase "my queen" and seeds a swarm thread
- The queen decomposes a problem into sub-ranges and posts a `claim` with the plan
- Workers see the thread, claim distinct sub-ranges, and post `result` notes
- Workers combine results into a `synthesis` with the final answer
- `swarm_create`, `swarm_read`, and `swarm_contribute` all function
### The ingestion fix
The first T1 run stalled after two subtasks. The cause was
[`maybe_fire_trigger_locked()`](../src/trigger_manager.c:928), which deduped by
`created_at` timestamp — distinct events sharing a second were dropped, and
cooldown discarded work rather than deferring it.
The fix: dedupe by **event id** using a 64-entry ring buffer per trigger
(`seen_event_ids` in [`active_trigger_t`](../src/trigger_manager.h:30)). Timestamp
dedup is gone; the ring buffer catches genuine replays without dropping distinct
events. After the fix, the swarm ran to completion.
### Observed T1 run (after the fix)
The admin posted: *"My queen, find the sum of all prime numbers between 1 and 1000.
Decompose the range across workers and have them report subtotals, then synthesize the total."*
The resulting thread:
| Author | Type | Content |
|--------|------|---------|
| queen | root | "Find the sum of all prime numbers between 1 and 1000. The range [1, 1000] is decomposed into 5 independent sub-ranges..." |
| queen | claim | "Proposed decomposition: split [1, 1000] into 5 equal sub-ranges of 200 integers each..." |
| worker1 | claim | "Claiming Worker C: [401, 600]" |
| worker1 | result | "Worker C result: [401, 600] — Primes (31 total) ... Subtotal: 15,409" |
| worker2 | claim | "Claiming Worker A: [2, 200] and Worker D: [601, 800]" |
| worker2 | result | "Worker A result: [2, 200] — Primes (46 total) ... Subtotal: 4,227" |
| worker2 | result | "Worker D result: [601, 800] — Primes (30 total) ... Subtotal: 20,782" |
| worker1 | result | "Worker E result: [801, 1000] — Primes (29 total) ... Subtotal: 26,049" |
| worker1 | result | "Worker B result: [201, 400] — Primes (32 total) ... Subtotal: 9,660" |
| worker1 | synthesis | "**Synthesis: Sum of all primes between 1 and 1000** ... Total 168 primes, **76,127**" |
| worker2 | synthesis | "**Synthesis: Sum of all primes between 1 and 1000** ... Grand total: **76,127**" |
All five subtotals are exactly correct, and both syntheses report the correct
total: **76,127**.
### Two findings from live client testing
**1. The queen must always seed a swarm.** The first skill said "If it is a
simple question, you may answer directly instead." When the admin asked for the
sum of primes 1–2000, the queen computed it herself with `local_shell_exec`
instead of seeding a swarm. The skill now says: *"When addressed, you ALWAYS
seed a swarm. Do not answer the question yourself... Never call
`local_shell_exec`."* After the update, she decomposed the range into four
sub-ranges and the swarm solved it correctly (277,050).
**2. Triggered skills have no output channel.** [`agent_on_trigger()`](../src/agent.c:1771)
runs the LLM loop but only sends a DM on *errors*. A triggered skill's final
text is discarded — there is no way for a triggered skill to reply to the admin.
This is why the queen's direct answer never reached the client. For the swarm
this is fine (the answer lives in the thread), but it is a real gap for any
triggered skill that wants to report back. A future `notify_admin` tool or a
trigger-level "reply to source" option would close it.
### Observations
- **The queen's decomposition was good.** Five equal sub-ranges, each a clean
independent unit of work.
- **Workers self-organized.** They claimed distinct ranges without a coordinator.
One worker took two ranges, the other took three.
- **Both workers synthesized.** The plan allows competing syntheses; both arrived
at the same correct answer. This is redundancy, not conflict.
- **No duplicate claims.** Each range was claimed once.
- **The swarm is chatty.** Every contribution triggers every worker, so the thread
grows quickly. Cost control (batching, budgets) is a real future need.
- **Workers may over-claim.** In the 1–2000 run, both workers claimed all four
subtasks at once ("all four sub-ranges are unclaimed, so I'll compute them
all"). They raced, but both produced correct results. A claim is advisory, so
this is expected — but it doubles the work. A future improvement is to have
workers re-read the thread immediately before claiming.
- **The queen's skill is editable on the relay.** Because genesis is consumed
once, the skill was updated by publishing a new kind 31124 event as the queen.
The running agent picked it up live via its self-skill subscription — no
restart needed. This is the intended way to evolve an agent.
### Artifacts
| Path | Purpose |
|------|---------|
| `swarm/queen/` | Queen config, keys, run script, log |
| `swarm/worker1/` | Worker 1 config, keys, run script, log |
| `swarm/worker2/` | Worker 2 config, keys, run script, log |
| `swarm/admin_post.sh` | Publish a kind 1 note as the admin via n_signer |
| `src/tools/tool_swarm.c` | The three swarm tools |
The admin key is the n_signer on the `nostr_signer` qube (role `main`, path
`m/44'/1237'/0'/0/0`), reached via `qrexec nostr_signer:qubes.SignerRpc`. The
admin key never leaves the signer qube.
## Scope
This is a **proof of concept**, not a product. It answers one question: *can a queen seed a swarm, can workers contribute to a shared thread, and can the swarm produce a result?*
Everything runs locally. No public relays, no spawning, no discovery, no human recruitment. Those come later.
## Constraints
**Local relay only.** Every agent connects to exactly one relay: `ws://127.0.0.1:7777`. This is already the first entry in [`DEFAULT_RELAYS`](src/default_events.h:32), so the PoC configs simply list it alone.
Why this matters:
- **Deterministic** — no public relay rate limits, no partial views, no network flakiness
- **Observable** — we can query the relay directly to see every event the swarm produced
- **Isolated** — the swarm cannot accidentally post to the real network
- **Fast** — local round trips are milliseconds
Each agent's kind 10002 relay list contains only `ws://127.0.0.1:7777`. The startup config must not include any other relay, or the agent will sync to it.
## Addressing the Queen
The queen watches the admin's timeline. She needs to know when she is being addressed.
### The mechanism: the phrase "my queen"
Keep it simple. The queen's trigger fires on the admin's kind 1 posts, and she acts only when the post body contains the phrase **"my queen"** (case-insensitive).
```
My queen, what is 2 + 2?
my queen: find the sum of primes under 1000
MY QUEEN what time is it
```
No `p` tags. No npub. No client configuration. The admin just types the phrase.
### How it works
The trigger filter is just the admin's posts:
```json
{"kinds": [1], "authors": ["<ADMIN_PUBKEY>"]}
```
The queen skill instructs the LLM to check the body for "my queen" (case-insensitive) and to do nothing if the phrase is absent. The LLM handles the case-insensitivity naturally — no C code needed.
This means the LLM runs on every admin post, but the queen's skill makes the no-op path cheap: if "my queen" is not present, she stops immediately without calling any tools.
### Why this is fine for the PoC
| Approach | Setup cost | Precision | Notes |
|----------|-----------|-----------|-------|
| `p` tag mention | Must know the queen's npub; client must add the tag | High | More correct, more friction |
| "my queen" phrase | None — just type it | Good enough | Zero friction, trivial to test |
For a proof of concept on a local relay, the phrase is the right call. It removes an entire class of setup problems (generating keys, computing pubkeys, baking them into filters, getting clients to emit tags) and lets us focus on the swarm itself.
A `p`-tag mechanism can be added later as a more precise alternative, but it is not needed to prove the model.
### Flow
```mermaid
sequenceDiagram
participant Admin
participant Relay as Local Relay
participant Queen
participant Worker
Admin->>Relay: kind 1: My queen, what is 2+2?
Relay->>Queen: trigger fires (authors=admin)
Note over Queen: body contains "my queen" -> proceed
Queen->>Relay: swarm_create: root with t=didactyl-swarm,<br/>swarm=root, e=admin_post
Relay->>Worker: trigger fires (#t=didactyl-swarm)
Worker->>Relay: swarm_contribute: result
Relay->>Queen: sees the result
Queen->>Relay: swarm_contribute: synthesis
```
The queen's `swarm_create` anchors the thread to the admin's original post with an `e` tag, so the swarm is traceable back to the request.
## Test Problems
A good swarm problem must be **decomposable**, **verifiable**, and **self-contained** on the local relay. Here is a progression, from smoke test to real collaboration.
### T0 — Smoke Test: "The queen wakes up"
> Admin posts: `My queen, what is 2 + 2? Have a worker answer.`
The queen sees the phrase, creates a thread root anchored to the admin's post, and a worker replies with "4".
**Verifies:** the "my queen" addressing works, the queen's trigger fires, the thread root is published, a worker sees it and contributes.
**Why start here:** it isolates the plumbing — addressing, trigger, root, contribution. If this fails, nothing else will work.
**Note:** the "Have a worker answer" phrasing forces the swarm path. Without it, the queen might reasonably answer a trivial question directly, which would test addressing but not the swarm.
### T1 — Decomposable Computation: "Sum of primes"
> Admin posts: "Find the sum of all prime numbers between 1 and 1000."
The queen decomposes the range into chunks (1–250, 251–500, 501–750, 751–1000), posts a thread root with the plan, and each worker claims a chunk, computes its subtotal, and posts a `result`. The queen (or any worker) posts a `synthesis` with the total.
**Verifies:** decomposition, claims, parallel work, synthesis.
**Ground truth:** 76127. We can check the final answer exactly.
**Why this is good:** the work is genuinely parallel, the answer is verifiable, and the LLM's arithmetic is checkable. If a worker gets a subtotal wrong, the synthesis exposes it.
### T2 — Relay Scavenger Hunt: "Assemble the fragments"
> Before the test, seed the local relay with 20 kind 1 notes, each containing a fragment (a word or number). Admin posts: "Find all 20 fragments tagged `#poc-fragment` and combine them into the final message."
The queen creates a thread, workers query the relay for fragments, each claims a subset, posts what it found, and the swarm assembles the complete message.
**Verifies:** relay discovery, division of a search space, assembly of partial results.
**Ground truth:** we know the fragments and the expected message.
**Why this is good:** it exercises the *discovery* path — workers must query the relay, not just read the thread. It also tests that the swarm can handle a task where the input is scattered.
### T3 — Research Synthesis: "Cross-reference the dataset"
> Seed the relay with 30 notes, each a city with a population. Admin posts: "What is the total population of the five largest cities in the dataset?"
Workers query and filter the dataset, each handling a subset, and the swarm synthesizes the answer.
**Verifies:** filtering, ranking, aggregation, and a synthesis that depends on all workers' results.
**Ground truth:** computed from the seeded data.
**Why this is good:** it is the closest to a real research task, and it requires the synthesis step to actually combine partial results rather than just concatenate them.
### Recommendation
Build **T0 first**, then **T1**. T0 proves the plumbing; T1 proves real collaboration with a checkable answer. T2 and T3 are stretch goals that exercise discovery and aggregation.
T1 is the sweet spot: simple enough to debug, real enough to be convincing, and the answer is a single number we can verify.
## Queen Implementation (Minimal)
The queen is a normal Didactyl agent with a queen skill. No new C code is needed for the queen itself — it is a skill plus the swarm tools.
### Queen skill
A kind 31124 private skill with a `nostr-subscription` trigger on the admin's kind 1 notes:
```json
{
"kind": 31124,
"content": "## Queen\n\nYou watch your administrator's public notes. When the admin addresses you, you seed a swarm.\n\n### Addressing\n\nYou only respond when the admin's post contains the phrase \"my queen\" (case-insensitive). If the phrase is not present, do nothing — stop immediately and call no tools.\n\n### When to seed a swarm\n\nWhen addressed, seed a swarm if the problem is decomposable into independent subtasks. If it is a simple question, you may answer directly instead.\n\n### How to seed\n\n1. Read the admin's post carefully.\n2. Decide whether it is worth a swarm.\n3. If yes, call `swarm_create` with a clear title and the problem statement.\n4. Post a `claim` on your own thread describing the decomposition you propose.\n5. Stop. Workers will pick up the subtasks.\n\n### Rules\n\n- You have no authority. You propose; workers decide.\n- Keep the thread root focused on the problem, not on you.\n- If a worker posts a result, you may post a `synthesis` when all subtasks are done.",
"tags": [
["d", "queen"],
["app", "didactyl"],
["scope", "private"],
["description", "Seed swarms from admin posts"],
["trigger", "nostr-subscription"],
["filter", "{\"kinds\":[1],\"authors\":[\"<ADMIN_PUBKEY>\"]}"],
["tools", "swarm_create,swarm_read,swarm_contribute,nostr_post,nostr_query"]
]
}
```
The filter is just the admin's posts. The queen skill decides whether to act by checking the body for "my queen" (case-insensitive). No pubkey or tag setup is required.
### Worker skill
A kind 31124 private skill with a `nostr-subscription` trigger on the swarm hashtag:
```json
{
"kind": 31124,
"content": "## Worker\n\nYou participate in swarms. A swarm is a shared thread — a kind 1 conversation where agents work a common problem.\n\n### Protocol\n\n1. When a swarm thread appears, read it with `swarm_read`.\n2. Look for `claim` notes to see what others are doing.\n3. If you can help, post a `claim` for a subtask nobody has taken.\n4. Do the work, then post a `result` with your findings.\n5. If you are blocked, post a `question`.\n6. If you can combine the results into a final answer, post a `synthesis`.\n\n### Rules\n\n- Do not duplicate work someone else has claimed.\n- Keep contributions concise and concrete.\n- Post the actual answer, not a description of how you would find it.",
"tags": [
["d", "worker"],
["app", "didactyl"],
["scope", "private"],
["description", "Participate in swarms"],
["trigger", "nostr-subscription"],
["filter", "{\"kinds\":[1],\"#t\":[\"didactyl-swarm\"]}"],
["tools", "swarm_read,swarm_contribute,nostr_post,nostr_query"]
]
}
```
### Swarm tools needed
Only three tools are required for the PoC:
| Tool | Purpose |
|------|---------|
| `swarm_create` | Publish a thread root with `["t","didactyl-swarm"]` and `["swarm","root"]` |
| `swarm_read` | Fetch the root and all `e`-linked replies, return them as structured JSON |
| `swarm_contribute` | Publish a kind 1 reply with root/reply `e` tags and an optional `swarm-type` |
`swarm_read` and `swarm_contribute` are the workhorses. `swarm_create` is only used by the queen.
## Worker Setup
For the PoC, workers are **launched manually** — no spawning. Each is a normal Didactyl instance with:
- Its own nsec (distinct identity)
- The same admin pubkey
- A relay list containing only `ws://127.0.0.1:7777`
- The worker skill adopted
- Its own API port (so we can inspect each one)
Three instances is enough: one queen, two workers.
```mermaid
flowchart TD
RELAY[ws://127.0.0.1:7777]
ADMIN[Admin<br/>posts problem]
QUEEN[Queen<br/>api :8484]
W1[Worker 1<br/>api :8485]
W2[Worker 2<br/>api :8486]
ADMIN -->|kind 1| RELAY
RELAY -->|trigger| QUEEN
QUEEN -->|swarm_create| RELAY
RELAY -->|trigger| W1
RELAY -->|trigger| W2
W1 -->|contribute| RELAY
W2 -->|contribute| RELAY
RELAY -->|read| QUEEN
```
## Test Harness
The existing [`AgentProcess`](tests/harness/agent_process.py:16) harness already supports launching an agent with a config, port, and log file. The PoC extends it to launch **three** instances and drive the scenario.
### Harness shape
```
tests/swarm/
configs/
queen.genesis.jsonc
worker1.genesis.jsonc
worker2.genesis.jsonc
seed_fragments.py # for T2/T3: publish seed events to the local relay
run_swarm_poc.py # orchestrates the scenario
```
### Scenario steps
1. Start the local relay at `ws://127.0.0.1:7777` (assumed running).
2. Launch the queen and two workers with their configs.
3. Wait for all three to reach the main poll loop.
4. (T2/T3 only) Seed the relay with the dataset.
5. Publish the admin's problem as a kind 1 note from the admin key.
6. Poll the relay for the thread root and contributions.
7. Wait for a `synthesis` or a timeout.
8. Assert the final answer matches the ground truth.
9. Dump the full thread for inspection.
### Observing the swarm
Because everything is on one local relay, we can inspect the entire swarm with a single query:
```json
{"kinds": [1], "#t": ["didactyl-swarm"]}
```
This returns every root and contribution. The harness can render the thread as a tree and check that:
- A root exists
- At least two distinct authors contributed
- Claims precede results
- A synthesis exists
- The synthesis contains the correct answer
## Success Criteria
| Criterion | How we check |
|-----------|--------------|
| Queen reacts to the admin post | A thread root exists authored by the queen |
| Workers see the thread | At least two distinct worker pubkeys appear in the thread |
| Work is divided | At least two `claim` notes with different subtasks |
| Results are posted | At least two `result` notes |
| The swarm converges | A `synthesis` note exists |
| The answer is correct | The synthesis contains the ground-truth answer |
| No duplicate work | No two workers claim the same subtask |
## What This PoC Deliberately Excludes
- **Spawning** — workers are launched manually
- **Discovery** — workers are pre-configured, not found via kind 0
- **Human recruitment** — no DMs to humans
- **Gating** — the thread is open; no policy enforcement
- **Side threads** — a single flat thread
- **Reliable ingestion** — the known timestamp-dedup issue is not fixed yet; the PoC uses spaced-out posts to avoid it
These are all in [`plans/swarm.md`](swarm.md). The PoC proves the core loop before we build the rest.
## Implementation Steps
1. **Write the three genesis configs** — queen, worker1, worker2, each with only `ws://127.0.0.1:7777` in the relay list and the appropriate skill.
2. **Implement `swarm_create`** — publish a kind 1 root with the swarm tags.
3. **Implement `swarm_read`** — query the root and its `e`-linked replies, return structured JSON.
4. **Implement `swarm_contribute`** — publish a kind 1 reply with root/reply `e` tags and an optional `swarm-type`.
5. **Register the three tools** in [`tools_internal.h`](src/tools/tools_internal.h), [`tools_dispatch.c`](src/tools/tools_dispatch.c), and [`tools_schema.c`](src/tools/tools_schema.c).
6. **Write the queen and worker skills** into the configs.
7. **Run T0** — verify the queen wakes up and a worker replies.
8. **Run T1** — verify the swarm computes the sum of primes correctly.
9. **Write the harness** — automate the scenario and the assertions.
10. **Run T2/T3** — stretch goals for discovery and aggregation.
## Open Questions
1. **How does the queen know a post is a problem?** The phrase "my queen" (case-insensitive) in the body is the signal. The queen skill can still decline to seed a swarm for trivial questions.
2. **How do workers avoid duplicate claims?** For the PoC, `swarm_read` before claiming is enough. Race conditions are acceptable at this scale.
3. **Who synthesizes?** For the PoC, allow any worker to synthesize. The queen can also do it. We do not need to reserve it.
4. **How long do we wait?** The harness should wait for a `synthesis` with a generous timeout, then report what it saw.
+14 -2
View File
@@ -1770,6 +1770,7 @@ void agent_set_trigger_manager(struct trigger_manager* trigger_manager) {
void agent_on_trigger(const char* skill_d_tag, void agent_on_trigger(const char* skill_d_tag,
const char* skill_content, const char* skill_content,
const char* tools_policy,
cJSON* triggering_event, cJSON* triggering_event,
const char* relay_url) { const char* relay_url) {
if (!g_cfg || !skill_d_tag || !skill_content || !triggering_event) { if (!g_cfg || !skill_d_tag || !skill_content || !triggering_event) {
@@ -1830,7 +1831,9 @@ void agent_on_trigger(const char* skill_d_tag,
return; return;
} }
char* tools_json = tools_build_openai_schema_json(&g_tools_ctx); /* Expose only the tools the skill's `tools` tag permits. An empty or
* wildcard policy exposes everything (backward compatible). */
char* tools_json = tools_build_openai_schema_json_filtered(&g_tools_ctx, tools_policy);
if (!tools_json) { if (!tools_json) {
context_roles_free(&roles); context_roles_free(&roles);
free(full_markdown); free(full_markdown);
@@ -1909,7 +1912,16 @@ void agent_on_trigger(const char* skill_d_tag,
for (int i = 0; i < resp.tool_call_count; i++) { for (int i = 0; i < resp.tool_call_count; i++) {
llm_tool_call_t* tc = &resp.tool_calls[i]; llm_tool_call_t* tc = &resp.tool_calls[i];
char* tool_result = tools_execute(&g_tools_ctx, tc->name, tc->arguments_json); char* tool_result = NULL;
/* Runtime-enforced policy: even if the LLM emits a tool call for a
* name outside the skill's allowlist, refuse to execute it. */
if (!tool_allowed_by_policy(tc->name, tools_policy)) {
DEBUG_WARN("[didactyl] trigger tool blocked by skill policy: d_tag=%s tool=%s",
skill_d_tag, tc->name ? tc->name : "<null>");
tool_result = strdup("{\"success\":false,\"error\":\"tool not permitted by skill policy\"}");
} else {
tool_result = tools_execute(&g_tools_ctx, tc->name, tc->arguments_json);
}
if (!tool_result) { if (!tool_result) {
tool_result = strdup("{\"success\":false,\"error\":\"tool execution failed\"}"); tool_result = strdup("{\"success\":false,\"error\":\"tool execution failed\"}");
} }
+1
View File
@@ -16,6 +16,7 @@ void agent_set_signer(nostr_signer_t* signer);
void agent_set_trigger_manager(struct trigger_manager* trigger_manager); void agent_set_trigger_manager(struct trigger_manager* trigger_manager);
void agent_on_trigger(const char* skill_d_tag, void agent_on_trigger(const char* skill_d_tag,
const char* skill_content, const char* skill_content,
const char* tools_policy,
cJSON* triggering_event, cJSON* triggering_event,
const char* relay_url); const char* relay_url);
void agent_on_message(const char* sender_pubkey_hex, void agent_on_message(const char* sender_pubkey_hex,
+2 -2
View File
@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros // Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0 #define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 2 #define DIDACTYL_VERSION_MINOR 2
#define DIDACTYL_VERSION_PATCH 73 #define DIDACTYL_VERSION_PATCH 74
#define DIDACTYL_VERSION "v0.2.73" #define DIDACTYL_VERSION "v0.2.74"
// Agent metadata // Agent metadata
#define DIDACTYL_NAME "Didactyl" #define DIDACTYL_NAME "Didactyl"
+266
View File
@@ -0,0 +1,266 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../debug.h"
#include "../nostr_handler.h"
/* nostr_reply — reply to a Nostr event (NIP-10).
*
* This is a general primitive: any agent, in a swarm or not, should be able
* to reply to a note. It handles the NIP-10 threading tags correctly:
*
* ["e", <parent_id>, <relay_hint>, "reply"]
* ["e", <root_id>, <relay_hint>, "root"] (only if the parent is itself
* a reply, so the whole thread
* stays connected)
* ["p", <parent_author>] (so the parent author is
* notified and clients thread it)
*
* The parent event is fetched to discover its author and its root, so the
* caller only needs to supply the event id and the reply text.
*/
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
static const char* get_string_local(cJSON* obj, const char* key) {
cJSON* item = cJSON_GetObjectItemCaseSensitive(obj, key);
if (item && cJSON_IsString(item) && item->valuestring && item->valuestring[0] != '\0') {
return item->valuestring;
}
return NULL;
}
/* Find the first tag with the given key and return its value at index 1.
* Returns a strdup'd string or NULL. */
static char* first_tag_value_local(cJSON* event, const char* key) {
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) return NULL;
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag)) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (k && cJSON_IsString(k) && k->valuestring &&
strcmp(k->valuestring, key) == 0 &&
v && cJSON_IsString(v) && v->valuestring) {
return strdup(v->valuestring);
}
}
return NULL;
}
/* Find the "root" e-tag value (the 4th element == "root"), or NULL. */
static char* find_root_e_tag_local(cJSON* event) {
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) return NULL;
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag)) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
cJSON* marker = cJSON_GetArrayItem(tag, 3);
if (k && cJSON_IsString(k) && k->valuestring &&
strcmp(k->valuestring, "e") == 0 &&
v && cJSON_IsString(v) && v->valuestring &&
marker && cJSON_IsString(marker) && marker->valuestring &&
strcmp(marker->valuestring, "root") == 0) {
return strdup(v->valuestring);
}
}
return NULL;
}
/* Fetch an event by id. Returns a cJSON object (caller frees) or NULL. */
static cJSON* fetch_event_local(const char* event_id) {
if (!event_id || event_id[0] == '\0') return NULL;
cJSON* filter = cJSON_CreateObject();
cJSON* ids = cJSON_CreateArray();
if (!filter || !ids) {
cJSON_Delete(filter);
cJSON_Delete(ids);
return NULL;
}
cJSON_AddItemToArray(ids, cJSON_CreateString(event_id));
cJSON_AddItemToObject(filter, "ids", ids);
cJSON_AddNumberToObject(filter, "limit", 1);
char* events_json = nostr_handler_query_json(filter, 5000);
cJSON_Delete(filter);
if (!events_json) return NULL;
cJSON* arr = cJSON_Parse(events_json);
free(events_json);
if (!arr || !cJSON_IsArray(arr) || cJSON_GetArraySize(arr) <= 0) {
cJSON_Delete(arr);
return NULL;
}
cJSON* ev = cJSON_Duplicate(cJSON_GetArrayItem(arr, 0), 1);
cJSON_Delete(arr);
return ev;
}
/* Append ["e", id, relay_hint, marker] to tags. */
static void add_e_tag_local(cJSON* tags, const char* id, const char* relay_hint, const char* marker) {
cJSON* e = cJSON_CreateArray();
if (!e) return;
cJSON_AddItemToArray(e, cJSON_CreateString("e"));
cJSON_AddItemToArray(e, cJSON_CreateString(id));
cJSON_AddItemToArray(e, cJSON_CreateString(relay_hint ? relay_hint : ""));
cJSON_AddItemToArray(e, cJSON_CreateString(marker ? marker : ""));
cJSON_AddItemToArray(tags, e);
}
/* Append ["p", pubkey] to tags. */
static void add_p_tag_local(cJSON* tags, const char* pubkey) {
if (!pubkey || pubkey[0] == '\0') return;
cJSON* p = cJSON_CreateArray();
if (!p) return;
cJSON_AddItemToArray(p, cJSON_CreateString("p"));
cJSON_AddItemToArray(p, cJSON_CreateString(pubkey));
cJSON_AddItemToArray(tags, p);
}
char* execute_nostr_reply(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
const char* event_id = get_string_local(args, "event_id");
const char* content = get_string_local(args, "content");
const char* relay_hint = get_string_local(args, "relay_hint");
if (!event_id || !content) {
cJSON_Delete(args);
return json_error_local("nostr_reply requires 'event_id' and 'content'");
}
/* Optional kind override (default 1). */
int kind = 1;
cJSON* kind_j = cJSON_GetObjectItemCaseSensitive(args, "kind");
if (kind_j && cJSON_IsNumber(kind_j)) {
kind = (int)kind_j->valuedouble;
}
/* Copy the ids we need after args is freed. */
char parent_id[128];
char hint[256];
snprintf(parent_id, sizeof(parent_id), "%s", event_id);
snprintf(hint, sizeof(hint), "%s", relay_hint ? relay_hint : "");
/* Fetch the parent so we can thread correctly. */
cJSON* parent = fetch_event_local(parent_id);
char* parent_author = parent ? first_tag_value_local(parent, "p") : NULL;
/* The parent's author is its "pubkey" field, not a p-tag. */
if (parent) {
cJSON* pk = cJSON_GetObjectItemCaseSensitive(parent, "pubkey");
if (pk && cJSON_IsString(pk) && pk->valuestring) {
free(parent_author);
parent_author = strdup(pk->valuestring);
}
}
/* If the parent is itself a reply, find the thread root so we keep the
* whole conversation connected. */
char* root_id = parent ? find_root_e_tag_local(parent) : NULL;
cJSON* tags = cJSON_CreateArray();
if (!tags) {
free(parent_author);
free(root_id);
cJSON_Delete(parent);
cJSON_Delete(args);
return json_error_local("nostr_reply allocation failure");
}
/* NIP-10: include the root first (if any), then the direct parent. */
if (root_id && root_id[0] != '\0' && strcmp(root_id, parent_id) != 0) {
add_e_tag_local(tags, root_id, hint[0] ? hint : NULL, "root");
}
add_e_tag_local(tags, parent_id, hint[0] ? hint : NULL, "reply");
add_p_tag_local(tags, parent_author);
/* Allow the caller to add extra tags. */
cJSON* extra_tags = cJSON_GetObjectItemCaseSensitive(args, "tags");
if (extra_tags && cJSON_IsArray(extra_tags)) {
int n = cJSON_GetArraySize(extra_tags);
for (int i = 0; i < n; i++) {
cJSON* t = cJSON_GetArrayItem(extra_tags, i);
if (t && cJSON_IsArray(t)) {
cJSON_AddItemToArray(tags, cJSON_Duplicate(t, 1));
}
}
}
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event(kind, content, tags, &publish_result);
cJSON_Delete(tags);
cJSON_Delete(parent);
cJSON_Delete(args);
if (rc != 0) {
free(parent_author);
free(root_id);
nostr_handler_publish_result_free(&publish_result);
return json_error_local("nostr_reply failed to publish");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
free(parent_author);
free(root_id);
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "message", "reply published");
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
cJSON_AddStringToObject(out, "reply_to", parent_id);
if (root_id && root_id[0] != '\0') {
cJSON_AddStringToObject(out, "thread_root", root_id);
}
if (parent_author && parent_author[0] != '\0') {
cJSON_AddStringToObject(out, "reply_to_author", parent_author);
}
cJSON_AddNumberToObject(out, "relays_published", publish_result.accepted_by_pool_count);
free(parent_author);
free(root_id);
nostr_handler_publish_result_free(&publish_result);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
+813
View File
@@ -0,0 +1,813 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include "cjson/cJSON.h"
#include "../debug.h"
#include "../nostr_handler.h"
/* Swarm thread model (see plans/swarm.md).
*
* A swarm is a Nostr thread rooted at the note that requested it — usually
* the admin's original post. The queen's seed note is a *reply* to that post
* (tagged ["swarm","seed"]), and every worker contribution is also a reply to
* the same root. This keeps the whole swarm in one NIP-10 thread, so any
* client renders it under the admin's post and the admin is notified.
*
* When there is no anchor (a standalone swarm), the seed note is itself the
* root and is tagged ["swarm","root"].
*
* Contributions are kind 1 replies that reference the root with an
* ["e", <root>, "", "root"] tag and carry an optional ["swarm-type", ...] tag.
*
* These tools are the minimum needed for the proof of concept:
* swarm_create - publish a thread seed (or root, if unanchored)
* swarm_read - fetch a root and its replies as structured JSON
* swarm_contribute - publish a reply in a thread
* swarm_claim - claim a task and win it deterministically (see below)
*
* Task claiming (optimistic locking). A claim is a contribution tagged
* ["swarm-type","claim"] and ["swarm-task", <task_id>]. Because workers read
* the thread and then publish, two workers can claim the same task in the
* same instant (a read-modify-write race). swarm_claim closes that window:
* it publishes the claim, waits a short settle period, re-reads the thread,
* and applies a deterministic tiebreak — the winning claim is the one with
* the lowest (created_at, event_id). The tool returns won=true only for the
* winner; losers get won=false and must not do the work.
*/
#define SWARM_TAG "didactyl-swarm"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
static const char* get_string_local(cJSON* obj, const char* key) {
cJSON* item = cJSON_GetObjectItemCaseSensitive(obj, key);
if (item && cJSON_IsString(item) && item->valuestring && item->valuestring[0] != '\0') {
return item->valuestring;
}
return NULL;
}
/* Build the tag array for a thread root. */
static cJSON* build_root_tags_local(const char* title,
const char* anchor_event_id,
const char* anchor_author_pubkey) {
cJSON* tags = cJSON_CreateArray();
if (!tags) return NULL;
cJSON* t = cJSON_CreateArray();
cJSON_AddItemToArray(t, cJSON_CreateString("t"));
cJSON_AddItemToArray(t, cJSON_CreateString(SWARM_TAG));
cJSON_AddItemToArray(tags, t);
/* When anchored to a parent note (the admin's post), this note is a
* "seed" reply in that thread, not a standalone root. When there is no
* anchor, it is a standalone root. */
int anchored = (anchor_event_id && anchor_event_id[0] != '\0');
cJSON* swarm = cJSON_CreateArray();
cJSON_AddItemToArray(swarm, cJSON_CreateString("swarm"));
cJSON_AddItemToArray(swarm, cJSON_CreateString(anchored ? "seed" : "root"));
cJSON_AddItemToArray(tags, swarm);
if (title && title[0] != '\0') {
cJSON* title_tag = cJSON_CreateArray();
cJSON_AddItemToArray(title_tag, cJSON_CreateString("swarm-title"));
cJSON_AddItemToArray(title_tag, cJSON_CreateString(title));
cJSON_AddItemToArray(tags, title_tag);
}
/* Anchor the thread to the note that requested it (usually the admin's
* post), so the swarm is traceable back to the request. */
if (anchor_event_id && anchor_event_id[0] != '\0') {
cJSON* e = cJSON_CreateArray();
cJSON_AddItemToArray(e, cJSON_CreateString("e"));
cJSON_AddItemToArray(e, cJSON_CreateString(anchor_event_id));
cJSON_AddItemToArray(e, cJSON_CreateString(""));
cJSON_AddItemToArray(e, cJSON_CreateString("root"));
cJSON_AddItemToArray(tags, e);
}
/* NIP-10: a reply should tag the parent author with a "p" tag. Without
* it, many clients will not thread the reply or notify the parent author,
* so the swarm would be invisible to the admin who asked. */
if (anchor_author_pubkey && anchor_author_pubkey[0] != '\0') {
cJSON* p = cJSON_CreateArray();
cJSON_AddItemToArray(p, cJSON_CreateString("p"));
cJSON_AddItemToArray(p, cJSON_CreateString(anchor_author_pubkey));
cJSON_AddItemToArray(tags, p);
}
return tags;
}
/* Build the tag array for a contribution. `task` is an optional stable task
* identifier (e.g. "subtask-1") used to detect competing claims. */
static cJSON* build_contribution_tags_local(const char* root_event_id,
const char* reply_to_event_id,
const char* swarm_type,
const char* task,
const char* root_author_pubkey) {
cJSON* tags = cJSON_CreateArray();
if (!tags) return NULL;
cJSON* root_e = cJSON_CreateArray();
cJSON_AddItemToArray(root_e, cJSON_CreateString("e"));
cJSON_AddItemToArray(root_e, cJSON_CreateString(root_event_id));
cJSON_AddItemToArray(root_e, cJSON_CreateString(""));
cJSON_AddItemToArray(root_e, cJSON_CreateString("root"));
cJSON_AddItemToArray(tags, root_e);
/* Only add a reply marker when it differs from the root, otherwise the
* event would carry two identical ["e", <id>, "", ...] tags. */
if (reply_to_event_id && reply_to_event_id[0] != '\0' &&
strcmp(reply_to_event_id, root_event_id) != 0) {
cJSON* reply_e = cJSON_CreateArray();
cJSON_AddItemToArray(reply_e, cJSON_CreateString("e"));
cJSON_AddItemToArray(reply_e, cJSON_CreateString(reply_to_event_id));
cJSON_AddItemToArray(reply_e, cJSON_CreateString(""));
cJSON_AddItemToArray(reply_e, cJSON_CreateString("reply"));
cJSON_AddItemToArray(tags, reply_e);
}
cJSON* t = cJSON_CreateArray();
cJSON_AddItemToArray(t, cJSON_CreateString("t"));
cJSON_AddItemToArray(t, cJSON_CreateString(SWARM_TAG));
cJSON_AddItemToArray(tags, t);
if (swarm_type && swarm_type[0] != '\0') {
cJSON* type_tag = cJSON_CreateArray();
cJSON_AddItemToArray(type_tag, cJSON_CreateString("swarm-type"));
cJSON_AddItemToArray(type_tag, cJSON_CreateString(swarm_type));
cJSON_AddItemToArray(tags, type_tag);
}
if (task && task[0] != '\0') {
cJSON* task_tag = cJSON_CreateArray();
cJSON_AddItemToArray(task_tag, cJSON_CreateString("swarm-task"));
cJSON_AddItemToArray(task_tag, cJSON_CreateString(task));
cJSON_AddItemToArray(tags, task_tag);
}
/* NIP-10: tag the root author so clients thread the contribution and
* notify the participant who started the thread. */
if (root_author_pubkey && root_author_pubkey[0] != '\0') {
cJSON* p = cJSON_CreateArray();
cJSON_AddItemToArray(p, cJSON_CreateString("p"));
cJSON_AddItemToArray(p, cJSON_CreateString(root_author_pubkey));
cJSON_AddItemToArray(tags, p);
}
return tags;
}
/* Extract a tag value by name from an event's tags array. Returns a strdup'd
* string or NULL. */
static char* event_tag_value_local(cJSON* event, const char* tag_name) {
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) return NULL;
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag)) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (k && cJSON_IsString(k) && k->valuestring &&
strcmp(k->valuestring, tag_name) == 0 &&
v && cJSON_IsString(v) && v->valuestring) {
return strdup(v->valuestring);
}
}
return NULL;
}
/* Return 1 if the event has an ["e", <id>, "", "root"] tag matching id. */
static int event_references_root_local(cJSON* event, const char* root_id) {
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) return 0;
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag)) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (k && cJSON_IsString(k) && k->valuestring &&
strcmp(k->valuestring, "e") == 0 &&
v && cJSON_IsString(v) && v->valuestring &&
strcmp(v->valuestring, root_id) == 0) {
return 1;
}
}
return 0;
}
/* Look up the author pubkey of an event by id. Returns a strdup'd hex string
* or NULL. Used to add the NIP-10 "p" tag when anchoring a thread root. */
static char* lookup_event_author_local(const char* event_id) {
if (!event_id || event_id[0] == '\0') return NULL;
cJSON* filter = cJSON_CreateObject();
cJSON* ids = cJSON_CreateArray();
if (!filter || !ids) {
cJSON_Delete(filter);
cJSON_Delete(ids);
return NULL;
}
cJSON_AddItemToArray(ids, cJSON_CreateString(event_id));
cJSON_AddItemToObject(filter, "ids", ids);
cJSON_AddNumberToObject(filter, "limit", 1);
char* events_json = nostr_handler_query_json(filter, 5000);
cJSON_Delete(filter);
if (!events_json) return NULL;
cJSON* arr = cJSON_Parse(events_json);
free(events_json);
if (!arr || !cJSON_IsArray(arr) || cJSON_GetArraySize(arr) <= 0) {
cJSON_Delete(arr);
return NULL;
}
cJSON* ev = cJSON_GetArrayItem(arr, 0);
cJSON* pk = ev ? cJSON_GetObjectItemCaseSensitive(ev, "pubkey") : NULL;
char* out = (pk && cJSON_IsString(pk) && pk->valuestring) ? strdup(pk->valuestring) : NULL;
cJSON_Delete(arr);
return out;
}
/* Resolve the NIP-10 thread root for a swarm thread.
*
* A swarm thread is rooted at the note that requested it (the admin's post).
* The queen's seed note is a reply to that post, and workers reply to the
* same root. Callers may pass either the true root or the seed note id; this
* resolves to the true root so every contribution lands in one thread.
*
* Returns a strdup'd event id, or NULL on failure (caller should fall back
* to the id it was given). */
static char* resolve_thread_root_local(const char* event_id) {
if (!event_id || event_id[0] == '\0') return NULL;
cJSON* filter = cJSON_CreateObject();
cJSON* ids = cJSON_CreateArray();
if (!filter || !ids) {
cJSON_Delete(filter);
cJSON_Delete(ids);
return NULL;
}
cJSON_AddItemToArray(ids, cJSON_CreateString(event_id));
cJSON_AddItemToObject(filter, "ids", ids);
cJSON_AddNumberToObject(filter, "limit", 1);
char* events_json = nostr_handler_query_json(filter, 5000);
cJSON_Delete(filter);
if (!events_json) return NULL;
cJSON* arr = cJSON_Parse(events_json);
free(events_json);
if (!arr || !cJSON_IsArray(arr) || cJSON_GetArraySize(arr) <= 0) {
cJSON_Delete(arr);
return NULL;
}
cJSON* ev = cJSON_GetArrayItem(arr, 0);
cJSON* tags = ev ? cJSON_GetObjectItemCaseSensitive(ev, "tags") : NULL;
char* out = NULL;
if (tags && cJSON_IsArray(tags)) {
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag)) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
cJSON* marker = cJSON_GetArrayItem(tag, 3);
if (k && cJSON_IsString(k) && k->valuestring &&
strcmp(k->valuestring, "e") == 0 &&
v && cJSON_IsString(v) && v->valuestring &&
marker && cJSON_IsString(marker) && marker->valuestring &&
strcmp(marker->valuestring, "root") == 0) {
out = strdup(v->valuestring);
break;
}
}
}
cJSON_Delete(arr);
return out;
}
char* execute_swarm_create(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
const char* title = get_string_local(args, "title");
const char* problem = get_string_local(args, "problem");
const char* anchor = get_string_local(args, "reply_to");
const char* anchor_author = get_string_local(args, "reply_to_author");
if (!problem) {
cJSON_Delete(args);
return json_error_local("swarm_create requires a non-empty 'problem' string");
}
/* Copy the anchor id before args is freed. */
char anchor_id[128] = {0};
if (anchor && anchor[0] != '\0') {
snprintf(anchor_id, sizeof(anchor_id), "%s", anchor);
}
/* If the caller did not supply the anchor author, look it up so we can add
* the NIP-10 "p" tag. Without it, clients will not thread the reply or
* notify the admin who asked. */
char* looked_up_author = NULL;
if (anchor_id[0] != '\0' && (!anchor_author || anchor_author[0] == '\0')) {
looked_up_author = lookup_event_author_local(anchor_id);
anchor_author = looked_up_author;
}
cJSON* tags = build_root_tags_local(title,
anchor_id[0] ? anchor_id : NULL,
anchor_author);
free(looked_up_author);
if (!tags) {
cJSON_Delete(args);
return json_error_local("swarm_create failed to build tags");
}
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event(1, problem, tags, &publish_result);
cJSON_Delete(tags);
cJSON_Delete(args);
if (rc != 0) {
nostr_handler_publish_result_free(&publish_result);
return json_error_local("swarm_create failed to publish the thread root");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "message", "swarm thread root published");
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
cJSON_AddNumberToObject(out, "relays_published", publish_result.accepted_by_pool_count);
if (title) cJSON_AddStringToObject(out, "title", title);
nostr_handler_publish_result_free(&publish_result);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_swarm_read(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
const char* thread = get_string_local(args, "thread");
if (!thread) {
cJSON_Delete(args);
return json_error_local("swarm_read requires a 'thread' event id");
}
int limit = 100;
cJSON* limit_j = cJSON_GetObjectItemCaseSensitive(args, "limit");
if (limit_j && cJSON_IsNumber(limit_j)) {
limit = (int)limit_j->valuedouble;
}
if (limit < 1) limit = 1;
if (limit > 500) limit = 500;
char thread_id[128];
snprintf(thread_id, sizeof(thread_id), "%s", thread);
cJSON_Delete(args);
/* Resolve to the true thread root (the admin's post) so a caller that
* passes the queen's seed id still reads the whole thread. */
char* resolved_root = resolve_thread_root_local(thread_id);
if (resolved_root && resolved_root[0] != '\0') {
snprintf(thread_id, sizeof(thread_id), "%s", resolved_root);
}
free(resolved_root);
/* Fetch the root by id. */
cJSON* root_filter = cJSON_CreateObject();
cJSON* root_ids = cJSON_CreateArray();
if (!root_filter || !root_ids) {
cJSON_Delete(root_filter);
cJSON_Delete(root_ids);
return json_error_local("swarm_read allocation failure");
}
cJSON_AddItemToArray(root_ids, cJSON_CreateString(thread_id));
cJSON_AddItemToObject(root_filter, "ids", root_ids);
cJSON_AddNumberToObject(root_filter, "limit", 1);
char* root_events_json = nostr_handler_query_json(root_filter, 5000);
cJSON_Delete(root_filter);
/* Fetch replies that reference the root. */
cJSON* reply_filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* e_vals = cJSON_CreateArray();
if (!reply_filter || !kinds || !e_vals) {
cJSON_Delete(reply_filter);
cJSON_Delete(kinds);
cJSON_Delete(e_vals);
free(root_events_json);
return json_error_local("swarm_read allocation failure");
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(1));
cJSON_AddItemToObject(reply_filter, "kinds", kinds);
cJSON_AddItemToArray(e_vals, cJSON_CreateString(thread_id));
cJSON_AddItemToObject(reply_filter, "#e", e_vals);
cJSON_AddNumberToObject(reply_filter, "limit", limit);
char* reply_events_json = nostr_handler_query_json(reply_filter, 5000);
cJSON_Delete(reply_filter);
cJSON* out = cJSON_CreateObject();
if (!out) {
free(root_events_json);
free(reply_events_json);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "thread", thread_id);
/* Root. */
cJSON* root_arr = root_events_json ? cJSON_Parse(root_events_json) : NULL;
cJSON* root_event = (root_arr && cJSON_IsArray(root_arr) && cJSON_GetArraySize(root_arr) > 0)
? cJSON_Duplicate(cJSON_GetArrayItem(root_arr, 0), 1)
: NULL;
if (root_event) {
cJSON_AddItemToObject(out, "root", root_event);
} else {
cJSON_AddNullToObject(out, "root");
}
cJSON_Delete(root_arr);
/* Contributions, grouped by swarm-type. */
cJSON* contributions = cJSON_CreateArray();
cJSON* by_type = cJSON_CreateObject();
cJSON* participants = cJSON_CreateArray();
if (!contributions || !by_type || !participants) {
cJSON_Delete(contributions);
cJSON_Delete(by_type);
cJSON_Delete(participants);
cJSON_Delete(out);
free(root_events_json);
free(reply_events_json);
return NULL;
}
cJSON* reply_arr = reply_events_json ? cJSON_Parse(reply_events_json) : NULL;
int reply_count = (reply_arr && cJSON_IsArray(reply_arr)) ? cJSON_GetArraySize(reply_arr) : 0;
for (int i = 0; i < reply_count; i++) {
cJSON* ev = cJSON_GetArrayItem(reply_arr, i);
if (!ev || !cJSON_IsObject(ev)) continue;
if (!event_references_root_local(ev, thread_id)) continue;
cJSON* entry = cJSON_CreateObject();
if (!entry) continue;
cJSON* id = cJSON_GetObjectItemCaseSensitive(ev, "id");
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(ev, "pubkey");
cJSON* content = cJSON_GetObjectItemCaseSensitive(ev, "content");
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
if (id && cJSON_IsString(id)) cJSON_AddStringToObject(entry, "id", id->valuestring);
if (pubkey && cJSON_IsString(pubkey)) cJSON_AddStringToObject(entry, "pubkey", pubkey->valuestring);
if (content && cJSON_IsString(content)) cJSON_AddStringToObject(entry, "content", content->valuestring);
if (created_at && cJSON_IsNumber(created_at)) cJSON_AddNumberToObject(entry, "created_at", created_at->valuedouble);
char* type = event_tag_value_local(ev, "swarm-type");
const char* type_name = type ? type : "untyped";
cJSON_AddStringToObject(entry, "swarm_type", type_name);
cJSON_AddItemToArray(contributions, entry);
/* Group by type. */
cJSON* bucket = cJSON_GetObjectItemCaseSensitive(by_type, type_name);
if (!bucket) {
bucket = cJSON_CreateArray();
cJSON_AddItemToObject(by_type, type_name, bucket);
}
cJSON* id_copy = (id && cJSON_IsString(id)) ? cJSON_CreateString(id->valuestring) : cJSON_CreateNull();
cJSON_AddItemToArray(bucket, id_copy);
/* Track distinct participants. */
if (pubkey && cJSON_IsString(pubkey) && pubkey->valuestring) {
int seen = 0;
int pn = cJSON_GetArraySize(participants);
for (int j = 0; j < pn; j++) {
cJSON* p = cJSON_GetArrayItem(participants, j);
if (p && cJSON_IsString(p) && p->valuestring &&
strcmp(p->valuestring, pubkey->valuestring) == 0) {
seen = 1;
break;
}
}
if (!seen) cJSON_AddItemToArray(participants, cJSON_CreateString(pubkey->valuestring));
}
free(type);
}
cJSON_Delete(reply_arr);
cJSON_AddItemToObject(out, "contributions", contributions);
cJSON_AddItemToObject(out, "by_type", by_type);
cJSON_AddItemToObject(out, "participants", participants);
cJSON_AddNumberToObject(out, "contribution_count", cJSON_GetArraySize(contributions));
free(root_events_json);
free(reply_events_json);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_swarm_contribute(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
const char* thread = get_string_local(args, "thread");
const char* content = get_string_local(args, "content");
const char* type = get_string_local(args, "type");
const char* reply_to = get_string_local(args, "reply_to");
const char* task = get_string_local(args, "task");
if (!thread || !content) {
cJSON_Delete(args);
return json_error_local("swarm_contribute requires 'thread' and 'content'");
}
/* Copy the strings we still need after args is freed. */
char thread_id[128];
char type_name[64];
char task_id[128];
snprintf(thread_id, sizeof(thread_id), "%s", thread);
snprintf(type_name, sizeof(type_name), "%s", type ? type : "untyped");
snprintf(task_id, sizeof(task_id), "%s", task ? task : "");
/* Resolve to the true thread root (the admin's post) so every
* contribution lands in one thread, even if the caller passed the
* queen's seed id. */
char* resolved_root = resolve_thread_root_local(thread_id);
if (resolved_root && resolved_root[0] != '\0') {
snprintf(thread_id, sizeof(thread_id), "%s", resolved_root);
}
free(resolved_root);
/* Look up the root author so we can add the NIP-10 "p" tag. */
char* root_author = lookup_event_author_local(thread_id);
cJSON* tags = build_contribution_tags_local(thread_id, reply_to, type,
task_id[0] ? task_id : NULL, root_author);
free(root_author);
if (!tags) {
cJSON_Delete(args);
return json_error_local("swarm_contribute failed to build tags");
}
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event(1, content, tags, &publish_result);
cJSON_Delete(tags);
cJSON_Delete(args);
if (rc != 0) {
nostr_handler_publish_result_free(&publish_result);
return json_error_local("swarm_contribute failed to publish");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "message", "swarm contribution published");
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
cJSON_AddStringToObject(out, "thread", thread_id);
cJSON_AddStringToObject(out, "swarm_type", type_name);
cJSON_AddNumberToObject(out, "relays_published", publish_result.accepted_by_pool_count);
nostr_handler_publish_result_free(&publish_result);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
/* ── swarm_claim: optimistic-locking task claim ────────────────────────────
*
* Publishes a claim for a task, waits a settle window, re-reads the thread,
* and decides the winner deterministically. The winner is the claim with the
* lowest (created_at, event_id) among all claims for the same task. This
* resolves the read-modify-write race where two workers claim the same task
* in the same instant.
*
* Returns JSON with won=true/false. A worker must only do the work when
* won=true. */
/* Compare two claim events by (created_at, id). Returns <0 if a wins. */
static int claim_compare_local(cJSON* a, cJSON* b) {
cJSON* a_ts = cJSON_GetObjectItemCaseSensitive(a, "created_at");
cJSON* b_ts = cJSON_GetObjectItemCaseSensitive(b, "created_at");
double at = (a_ts && cJSON_IsNumber(a_ts)) ? a_ts->valuedouble : 0;
double bt = (b_ts && cJSON_IsNumber(b_ts)) ? b_ts->valuedouble : 0;
if (at < bt) return -1;
if (at > bt) return 1;
cJSON* a_id = cJSON_GetObjectItemCaseSensitive(a, "id");
cJSON* b_id = cJSON_GetObjectItemCaseSensitive(b, "id");
const char* ai = (a_id && cJSON_IsString(a_id)) ? a_id->valuestring : "";
const char* bi = (b_id && cJSON_IsString(b_id)) ? b_id->valuestring : "";
return strcmp(ai, bi);
}
/* Return 1 if the event is a claim for `task_id`. */
static int event_is_claim_for_task_local(cJSON* ev, const char* task_id) {
char* type = event_tag_value_local(ev, "swarm-type");
int is_claim = (type && strcmp(type, "claim") == 0);
free(type);
if (!is_claim) return 0;
char* task = event_tag_value_local(ev, "swarm-task");
int match = (task && strcmp(task, task_id) == 0);
free(task);
return match;
}
char* execute_swarm_claim(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
const char* thread = get_string_local(args, "thread");
const char* task = get_string_local(args, "task");
const char* content = get_string_local(args, "content");
const char* reply_to = get_string_local(args, "reply_to");
if (!thread || !task) {
cJSON_Delete(args);
return json_error_local("swarm_claim requires 'thread' and 'task'");
}
char thread_id[128];
char task_id[128];
snprintf(thread_id, sizeof(thread_id), "%s", thread);
snprintf(task_id, sizeof(task_id), "%s", task);
/* Default claim text if the caller did not supply one. */
char default_content[256];
if (!content) {
snprintf(default_content, sizeof(default_content), "Claiming %s.", task_id);
content = default_content;
}
cJSON_Delete(args);
/* Resolve to the true thread root so the claim lands in the right thread. */
char* resolved_root = resolve_thread_root_local(thread_id);
if (resolved_root && resolved_root[0] != '\0') {
snprintf(thread_id, sizeof(thread_id), "%s", resolved_root);
}
free(resolved_root);
char* root_author = lookup_event_author_local(thread_id);
cJSON* tags = build_contribution_tags_local(thread_id, reply_to, "claim",
task_id, root_author);
free(root_author);
if (!tags) {
return json_error_local("swarm_claim failed to build tags");
}
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event(1, content, tags, &publish_result);
cJSON_Delete(tags);
if (rc != 0) {
nostr_handler_publish_result_free(&publish_result);
return json_error_local("swarm_claim failed to publish the claim");
}
char my_event_id[128];
snprintf(my_event_id, sizeof(my_event_id), "%s",
publish_result.event_id ? publish_result.event_id : "");
nostr_handler_publish_result_free(&publish_result);
/* Settle window: give competing claims time to land on the relay before
* we decide the winner. */
struct timespec settle = { .tv_sec = 2, .tv_nsec = 0 };
nanosleep(&settle, NULL);
/* Re-read the thread and find all claims for this task. */
cJSON* reply_filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* e_vals = cJSON_CreateArray();
if (!reply_filter || !kinds || !e_vals) {
cJSON_Delete(reply_filter);
cJSON_Delete(kinds);
cJSON_Delete(e_vals);
return json_error_local("swarm_claim allocation failure");
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(1));
cJSON_AddItemToObject(reply_filter, "kinds", kinds);
cJSON_AddItemToArray(e_vals, cJSON_CreateString(thread_id));
cJSON_AddItemToObject(reply_filter, "#e", e_vals);
cJSON_AddNumberToObject(reply_filter, "limit", 500);
char* reply_events_json = nostr_handler_query_json(reply_filter, 5000);
cJSON_Delete(reply_filter);
cJSON* winner = NULL;
int claim_count = 0;
cJSON* reply_arr = reply_events_json ? cJSON_Parse(reply_events_json) : NULL;
int reply_count = (reply_arr && cJSON_IsArray(reply_arr)) ? cJSON_GetArraySize(reply_arr) : 0;
for (int i = 0; i < reply_count; i++) {
cJSON* ev = cJSON_GetArrayItem(reply_arr, i);
if (!ev || !cJSON_IsObject(ev)) continue;
if (!event_references_root_local(ev, thread_id)) continue;
if (!event_is_claim_for_task_local(ev, task_id)) continue;
claim_count++;
if (!winner || claim_compare_local(ev, winner) < 0) {
winner = ev;
}
}
int won = 0;
char winner_id[128] = {0};
char winner_pubkey[128] = {0};
if (winner) {
cJSON* wid = cJSON_GetObjectItemCaseSensitive(winner, "id");
cJSON* wpk = cJSON_GetObjectItemCaseSensitive(winner, "pubkey");
if (wid && cJSON_IsString(wid)) snprintf(winner_id, sizeof(winner_id), "%s", wid->valuestring);
if (wpk && cJSON_IsString(wpk)) snprintf(winner_pubkey, sizeof(winner_pubkey), "%s", wpk->valuestring);
won = (strcmp(winner_id, my_event_id) == 0);
}
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(reply_arr);
free(reply_events_json);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddBoolToObject(out, "won", won);
cJSON_AddStringToObject(out, "thread", thread_id);
cJSON_AddStringToObject(out, "task", task_id);
cJSON_AddStringToObject(out, "my_claim_event_id", my_event_id);
cJSON_AddNumberToObject(out, "competing_claims", claim_count);
if (winner_id[0]) cJSON_AddStringToObject(out, "winner_event_id", winner_id);
if (winner_pubkey[0]) cJSON_AddStringToObject(out, "winner_pubkey", winner_pubkey);
cJSON_AddStringToObject(out, "message",
won ? "claim won — proceed with the task"
: "claim lost — another worker won this task; do not do the work");
cJSON_Delete(reply_arr);
free(reply_events_json);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
+7
View File
@@ -28,6 +28,13 @@ void tools_cleanup(tools_context_t* ctx);
* after tools_cleanup. Passing NULL restores the legacy raw-key fallback. */ * after tools_cleanup. Passing NULL restores the legacy raw-key fallback. */
void tools_set_signer(tools_context_t* ctx, nostr_signer_t* signer); void tools_set_signer(tools_context_t* ctx, nostr_signer_t* signer);
char* tools_build_openai_schema_json(const tools_context_t* ctx); char* tools_build_openai_schema_json(const tools_context_t* ctx);
/* Build the tool schema filtered by a skill's `tools` allowlist (comma-
* separated). NULL/""/"*"/"all" returns the full schema. */
char* tools_build_openai_schema_json_filtered(const tools_context_t* ctx,
const char* policy_csv);
/* Return 1 if `tool_name` is permitted by `policy_csv`. Empty/wildcard policy
* permits everything. Shared by the schema filter and the execution guard. */
int tool_allowed_by_policy(const char* tool_name, const char* policy_csv);
char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* args_json); char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* args_json);
#endif #endif
+15
View File
@@ -47,6 +47,9 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch
if (strcmp(tool_name, "nostr_post") == 0) { if (strcmp(tool_name, "nostr_post") == 0) {
return execute_nostr_post(args_json); return execute_nostr_post(args_json);
} }
if (strcmp(tool_name, "nostr_reply") == 0) {
return execute_nostr_reply(ctx, args_json);
}
if (strcmp(tool_name, "signer_crypto") == 0) { if (strcmp(tool_name, "signer_crypto") == 0) {
return execute_signer_crypto(ctx, args_json); return execute_signer_crypto(ctx, args_json);
} }
@@ -321,6 +324,18 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch
if (strcmp(tool_name, "blossom_list") == 0) { if (strcmp(tool_name, "blossom_list") == 0) {
return execute_blossom_list(ctx, args_json); return execute_blossom_list(ctx, args_json);
} }
if (strcmp(tool_name, "swarm_create") == 0) {
return execute_swarm_create(ctx, args_json);
}
if (strcmp(tool_name, "swarm_read") == 0) {
return execute_swarm_read(ctx, args_json);
}
if (strcmp(tool_name, "swarm_contribute") == 0) {
return execute_swarm_contribute(ctx, args_json);
}
if (strcmp(tool_name, "swarm_claim") == 0) {
return execute_swarm_claim(ctx, args_json);
}
return json_error("unknown tool"); return json_error("unknown tool");
} }
+7
View File
@@ -53,6 +53,7 @@ char* execute_local_shell_exec(tools_context_t* ctx, const char* args_json);
char* execute_local_file_read(tools_context_t* ctx, const char* args_json); char* execute_local_file_read(tools_context_t* ctx, const char* args_json);
char* execute_local_file_write(tools_context_t* ctx, const char* args_json); char* execute_local_file_write(tools_context_t* ctx, const char* args_json);
char* execute_nostr_post(const char* args_json); char* execute_nostr_post(const char* args_json);
char* execute_nostr_reply(tools_context_t* ctx, const char* args_json);
char* execute_nostr_post_readme(tools_context_t* ctx, const char* args_json); char* execute_nostr_post_readme(tools_context_t* ctx, const char* args_json);
char* execute_nostr_file_md_to_longform_post(tools_context_t* ctx, const char* args_json); char* execute_nostr_file_md_to_longform_post(tools_context_t* ctx, const char* args_json);
char* execute_skill_create(tools_context_t* ctx, const char* args_json); char* execute_skill_create(tools_context_t* ctx, const char* args_json);
@@ -79,6 +80,12 @@ char* execute_trigger_event(tools_context_t* ctx, const char* args_json);
char* execute_nostr_dm_history(tools_context_t* ctx, const char* args_json); char* execute_nostr_dm_history(tools_context_t* ctx, const char* args_json);
char* execute_signer_crypto(tools_context_t* ctx, const char* args_json); char* execute_signer_crypto(tools_context_t* ctx, const char* args_json);
/* Swarm thread model (see plans/swarm.md) */
char* execute_swarm_create(tools_context_t* ctx, const char* args_json);
char* execute_swarm_read(tools_context_t* ctx, const char* args_json);
char* execute_swarm_contribute(tools_context_t* ctx, const char* args_json);
char* execute_swarm_claim(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_balance(tools_context_t* ctx, const char* args_json); char* execute_cashu_wallet_balance(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_info(tools_context_t* ctx, const char* args_json); char* execute_cashu_wallet_info(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_mint_quote(tools_context_t* ctx, const char* args_json); char* execute_cashu_wallet_mint_quote(tools_context_t* ctx, const char* args_json);
+299
View File
@@ -2349,6 +2349,223 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON_AddItemToObject(t68, "function", t68_fn); cJSON_AddItemToObject(t68, "function", t68_fn);
cJSON_AddItemToArray(tools, t68); cJSON_AddItemToArray(tools, t68);
/* ── Swarm thread model (see plans/swarm.md) ─────────────────────── */
cJSON* t69 = cJSON_CreateObject();
cJSON* t69_fn = cJSON_CreateObject();
cJSON* t69_params = cJSON_CreateObject();
cJSON* t69_props = cJSON_CreateObject();
cJSON* t69_required = cJSON_CreateArray();
cJSON_AddStringToObject(t69, "type", "function");
cJSON_AddStringToObject(t69_fn, "name", "swarm_create");
cJSON_AddStringToObject(t69_fn, "description",
"Seed a swarm thread: publish a kind 1 note that states a problem for a swarm to work on. "
"Tagged t=didactyl-swarm. When anchored to the note that requested it (reply_to), the note is a "
"reply in that thread (swarm=seed) and the whole swarm stays under the requester's post; without "
"an anchor it is a standalone root (swarm=root).");
cJSON_AddStringToObject(t69_params, "type", "object");
cJSON_AddItemToObject(t69_params, "properties", t69_props);
cJSON_AddItemToObject(t69_params, "required", t69_required);
cJSON* p69_problem = cJSON_CreateObject();
cJSON_AddStringToObject(p69_problem, "type", "string");
cJSON_AddStringToObject(p69_problem, "description", "The problem statement. This becomes the thread root content.");
cJSON_AddItemToObject(t69_props, "problem", p69_problem);
cJSON* p69_title = cJSON_CreateObject();
cJSON_AddStringToObject(p69_title, "type", "string");
cJSON_AddStringToObject(p69_title, "description", "Optional short human-readable title for the thread.");
cJSON_AddItemToObject(t69_props, "title", p69_title);
cJSON* p69_anchor = cJSON_CreateObject();
cJSON_AddStringToObject(p69_anchor, "type", "string");
cJSON_AddStringToObject(p69_anchor, "description",
"Optional event id to anchor the thread to (usually the admin's post that requested the swarm). "
"When set, this note becomes a reply in that thread and the swarm is rooted at the anchor.");
cJSON_AddItemToObject(t69_props, "reply_to", p69_anchor);
cJSON_AddItemToArray(t69_required, cJSON_CreateString("problem"));
cJSON_AddItemToObject(t69_fn, "parameters", t69_params);
cJSON_AddItemToObject(t69, "function", t69_fn);
cJSON_AddItemToArray(tools, t69);
cJSON* t70 = cJSON_CreateObject();
cJSON* t70_fn = cJSON_CreateObject();
cJSON* t70_params = cJSON_CreateObject();
cJSON* t70_props = cJSON_CreateObject();
cJSON* t70_required = cJSON_CreateArray();
cJSON_AddStringToObject(t70, "type", "function");
cJSON_AddStringToObject(t70_fn, "name", "swarm_read");
cJSON_AddStringToObject(t70_fn, "description",
"Read a swarm thread: fetch the root and all contributions that reference it. "
"Accepts either the true thread root (the requester's post) or the queen's seed note id; "
"the seed is resolved to the true root. Returns the root, a flat list of contributions, "
"contributions grouped by swarm-type, and the distinct participants.");
cJSON_AddStringToObject(t70_params, "type", "object");
cJSON_AddItemToObject(t70_params, "properties", t70_props);
cJSON_AddItemToObject(t70_params, "required", t70_required);
cJSON* p70_thread = cJSON_CreateObject();
cJSON_AddStringToObject(p70_thread, "type", "string");
cJSON_AddStringToObject(p70_thread, "description",
"The thread root event id to read (the requester's post, or the queen's seed note id).");
cJSON_AddItemToObject(t70_props, "thread", p70_thread);
cJSON* p70_limit = cJSON_CreateObject();
cJSON_AddStringToObject(p70_limit, "type", "integer");
cJSON_AddStringToObject(p70_limit, "description", "Maximum contributions to fetch (default 100, max 500).");
cJSON_AddItemToObject(t70_props, "limit", p70_limit);
cJSON_AddItemToArray(t70_required, cJSON_CreateString("thread"));
cJSON_AddItemToObject(t70_fn, "parameters", t70_params);
cJSON_AddItemToObject(t70, "function", t70_fn);
cJSON_AddItemToArray(tools, t70);
cJSON* t71 = cJSON_CreateObject();
cJSON* t71_fn = cJSON_CreateObject();
cJSON* t71_params = cJSON_CreateObject();
cJSON* t71_props = cJSON_CreateObject();
cJSON* t71_required = cJSON_CreateArray();
cJSON_AddStringToObject(t71, "type", "function");
cJSON_AddStringToObject(t71_fn, "name", "swarm_contribute");
cJSON_AddStringToObject(t71_fn, "description",
"Post a contribution to a swarm thread: publish a kind 1 reply that references the thread root. "
"Accepts either the true thread root (the requester's post) or the queen's seed note id; the seed "
"is resolved to the true root so all contributions stay in one thread. "
"Use swarm-type to signal intent: claim, progress, result, question, or synthesis.");
cJSON_AddStringToObject(t71_params, "type", "object");
cJSON_AddItemToObject(t71_params, "properties", t71_props);
cJSON_AddItemToObject(t71_params, "required", t71_required);
cJSON* p71_thread = cJSON_CreateObject();
cJSON_AddStringToObject(p71_thread, "type", "string");
cJSON_AddStringToObject(p71_thread, "description",
"The thread root event id to contribute to (the requester's post, or the queen's seed note id).");
cJSON_AddItemToObject(t71_props, "thread", p71_thread);
cJSON* p71_content = cJSON_CreateObject();
cJSON_AddStringToObject(p71_content, "type", "string");
cJSON_AddStringToObject(p71_content, "description", "The contribution text.");
cJSON_AddItemToObject(t71_props, "content", p71_content);
cJSON* p71_type = cJSON_CreateObject();
cJSON_AddStringToObject(p71_type, "type", "string");
cJSON_AddStringToObject(p71_type, "description",
"Optional contribution type: claim, progress, result, question, or synthesis.");
cJSON_AddItemToObject(t71_props, "type", p71_type);
cJSON* p71_reply = cJSON_CreateObject();
cJSON_AddStringToObject(p71_reply, "type", "string");
cJSON_AddStringToObject(p71_reply, "description",
"Optional event id this contribution replies to (for threading within the swarm).");
cJSON_AddItemToObject(t71_props, "reply_to", p71_reply);
cJSON* p71_task = cJSON_CreateObject();
cJSON_AddStringToObject(p71_task, "type", "string");
cJSON_AddStringToObject(p71_task, "description",
"Optional stable task id this contribution addresses (e.g. 'subtask-1'). "
"Set it on claims and results so the swarm can detect competing claims.");
cJSON_AddItemToObject(t71_props, "task", p71_task);
cJSON_AddItemToArray(t71_required, cJSON_CreateString("thread"));
cJSON_AddItemToArray(t71_required, cJSON_CreateString("content"));
cJSON_AddItemToObject(t71_fn, "parameters", t71_params);
cJSON_AddItemToObject(t71, "function", t71_fn);
cJSON_AddItemToArray(tools, t71);
/* ── swarm_claim: optimistic-locking task claim ──────────────────── */
cJSON* t73 = cJSON_CreateObject();
cJSON* t73_fn = cJSON_CreateObject();
cJSON* t73_params = cJSON_CreateObject();
cJSON* t73_props = cJSON_CreateObject();
cJSON* t73_required = cJSON_CreateArray();
cJSON_AddStringToObject(t73, "type", "function");
cJSON_AddStringToObject(t73_fn, "name", "swarm_claim");
cJSON_AddStringToObject(t73_fn, "description",
"Claim a task in a swarm thread and win it deterministically. Publishes a claim, waits a short "
"settle window, re-reads the thread, and applies a tiebreak (lowest created_at, then lowest event id). "
"Returns won=true only for the winner. Do the work ONLY when won=true; if won=false, another worker "
"won the task and you must not duplicate it.");
cJSON_AddStringToObject(t73_params, "type", "object");
cJSON_AddItemToObject(t73_params, "properties", t73_props);
cJSON_AddItemToObject(t73_params, "required", t73_required);
cJSON* p73_thread = cJSON_CreateObject();
cJSON_AddStringToObject(p73_thread, "type", "string");
cJSON_AddStringToObject(p73_thread, "description",
"The thread root event id (the requester's post, or the queen's seed note id).");
cJSON_AddItemToObject(t73_props, "thread", p73_thread);
cJSON* p73_task = cJSON_CreateObject();
cJSON_AddStringToObject(p73_task, "type", "string");
cJSON_AddStringToObject(p73_task, "description",
"Stable task id to claim (e.g. 'subtask-1'). Must match the id the queen used in the decomposition.");
cJSON_AddItemToObject(t73_props, "task", p73_task);
cJSON* p73_content = cJSON_CreateObject();
cJSON_AddStringToObject(p73_content, "type", "string");
cJSON_AddStringToObject(p73_content, "description",
"Optional claim text. Defaults to 'Claiming <task>.'");
cJSON_AddItemToObject(t73_props, "content", p73_content);
cJSON* p73_reply = cJSON_CreateObject();
cJSON_AddStringToObject(p73_reply, "type", "string");
cJSON_AddStringToObject(p73_reply, "description",
"Optional event id this claim replies to (for threading within the swarm).");
cJSON_AddItemToObject(t73_props, "reply_to", p73_reply);
cJSON_AddItemToArray(t73_required, cJSON_CreateString("thread"));
cJSON_AddItemToArray(t73_required, cJSON_CreateString("task"));
cJSON_AddItemToObject(t73_fn, "parameters", t73_params);
cJSON_AddItemToObject(t73, "function", t73_fn);
cJSON_AddItemToArray(tools, t73);
/* ── nostr_reply: general NIP-10 reply primitive ─────────────────── */
cJSON* t72 = cJSON_CreateObject();
cJSON* t72_fn = cJSON_CreateObject();
cJSON* t72_params = cJSON_CreateObject();
cJSON* t72_props = cJSON_CreateObject();
cJSON* t72_required = cJSON_CreateArray();
cJSON_AddStringToObject(t72, "type", "function");
cJSON_AddStringToObject(t72_fn, "name", "nostr_reply");
cJSON_AddStringToObject(t72_fn, "description",
"Reply to a Nostr event (NIP-10). Fetches the parent to discover its author and thread root, "
"then publishes a reply with the correct e/p tags so clients thread it and the parent author is notified. "
"Use this to respond to any note, in a swarm or not.");
cJSON_AddStringToObject(t72_params, "type", "object");
cJSON_AddItemToObject(t72_params, "properties", t72_props);
cJSON_AddItemToObject(t72_params, "required", t72_required);
cJSON* p72_event = cJSON_CreateObject();
cJSON_AddStringToObject(p72_event, "type", "string");
cJSON_AddStringToObject(p72_event, "description", "The event id to reply to.");
cJSON_AddItemToObject(t72_props, "event_id", p72_event);
cJSON* p72_content = cJSON_CreateObject();
cJSON_AddStringToObject(p72_content, "type", "string");
cJSON_AddStringToObject(p72_content, "description", "The reply text.");
cJSON_AddItemToObject(t72_props, "content", p72_content);
cJSON* p72_kind = cJSON_CreateObject();
cJSON_AddStringToObject(p72_kind, "type", "integer");
cJSON_AddStringToObject(p72_kind, "description", "Optional event kind for the reply (default 1).");
cJSON_AddItemToObject(t72_props, "kind", p72_kind);
cJSON* p72_hint = cJSON_CreateObject();
cJSON_AddStringToObject(p72_hint, "type", "string");
cJSON_AddStringToObject(p72_hint, "description", "Optional relay hint to include in the e tags.");
cJSON_AddItemToObject(t72_props, "relay_hint", p72_hint);
cJSON* p72_tags = cJSON_CreateObject();
cJSON_AddStringToObject(p72_tags, "type", "array");
cJSON_AddStringToObject(p72_tags, "description", "Optional extra tags to add to the reply.");
cJSON* p72_tags_items = cJSON_CreateObject();
cJSON_AddStringToObject(p72_tags_items, "type", "array");
cJSON* p72_tag_item = cJSON_CreateObject();
cJSON_AddStringToObject(p72_tag_item, "type", "string");
cJSON_AddItemToObject(p72_tags_items, "items", p72_tag_item);
cJSON_AddItemToObject(p72_tags, "items", p72_tags_items);
cJSON_AddItemToObject(t72_props, "tags", p72_tags);
cJSON_AddItemToArray(t72_required, cJSON_CreateString("event_id"));
cJSON_AddItemToArray(t72_required, cJSON_CreateString("content"));
cJSON_AddItemToObject(t72_fn, "parameters", t72_params);
cJSON_AddItemToObject(t72, "function", t72_fn);
cJSON_AddItemToArray(tools, t72);
char* out = cJSON_PrintUnformatted(tools); char* out = cJSON_PrintUnformatted(tools);
cJSON_Delete(tools); cJSON_Delete(tools);
return out; return out;
@@ -2357,3 +2574,85 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
char* tools_build_openai_schema_json(const tools_context_t* ctx) { char* tools_build_openai_schema_json(const tools_context_t* ctx) {
return tools_build_openai_schema_json_legacy(ctx); return tools_build_openai_schema_json_legacy(ctx);
} }
/* ── Skill tool policy ─────────────────────────────────────────────────────
*
* A skill may declare a `tools` tag: a comma-separated allowlist of tool
* names. When present, only those tools are exposed to the LLM for that
* skill's execution. An empty/absent policy, or the wildcard "*"/"all",
* exposes every tool. Unknown names are ignored (a warning is logged).
*
* tool_allowed_by_policy() is the single source of truth, shared by the
* schema filter and the runtime execution guard so they cannot disagree. */
static int policy_is_wildcard(const char* policy_csv) {
if (!policy_csv) return 1;
const char* p = policy_csv;
while (*p == ' ' || *p == '\t') p++;
if (*p == '\0') return 1;
if (*p == '*') {
p++;
while (*p == ' ' || *p == '\t') p++;
return (*p == '\0');
}
/* "all" (case-insensitive), optionally surrounded by whitespace. */
if ((p[0] == 'a' || p[0] == 'A') &&
(p[1] == 'l' || p[1] == 'L') &&
(p[2] == 'l' || p[2] == 'L')) {
const char* q = p + 3;
while (*q == ' ' || *q == '\t') q++;
if (*q == '\0') return 1;
}
return 0;
}
int tool_allowed_by_policy(const char* tool_name, const char* policy_csv) {
if (!tool_name || tool_name[0] == '\0') return 0;
if (policy_is_wildcard(policy_csv)) return 1;
const char* p = policy_csv;
while (*p) {
while (*p == ' ' || *p == '\t' || *p == ',') p++;
const char* start = p;
while (*p && *p != ',') p++;
const char* end = p;
while (end > start && (end[-1] == ' ' || end[-1] == '\t')) end--;
size_t len = (size_t)(end - start);
if (len > 0 && strlen(tool_name) == len && strncmp(start, tool_name, len) == 0) {
return 1;
}
}
return 0;
}
char* tools_build_openai_schema_json_filtered(const tools_context_t* ctx,
const char* policy_csv) {
/* No policy or wildcard: return the full schema unchanged. */
if (policy_is_wildcard(policy_csv)) {
return tools_build_openai_schema_json(ctx);
}
char* full = tools_build_openai_schema_json(ctx);
if (!full) return NULL;
cJSON* arr = cJSON_Parse(full);
free(full);
if (!arr || !cJSON_IsArray(arr)) {
cJSON_Delete(arr);
return NULL;
}
for (int i = cJSON_GetArraySize(arr) - 1; i >= 0; i--) {
cJSON* item = cJSON_GetArrayItem(arr, i);
cJSON* fn = item ? cJSON_GetObjectItemCaseSensitive(item, "function") : NULL;
cJSON* name = fn ? cJSON_GetObjectItemCaseSensitive(fn, "name") : NULL;
if (!name || !cJSON_IsString(name) || !name->valuestring ||
!tool_allowed_by_policy(name->valuestring, policy_csv)) {
cJSON_DeleteItemFromArray(arr, i);
}
}
char* out = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
return out;
}
+41 -4
View File
@@ -15,6 +15,7 @@
void agent_on_trigger(const char* skill_d_tag, void agent_on_trigger(const char* skill_d_tag,
const char* skill_content, const char* skill_content,
const char* tools_policy,
cJSON* triggering_event, cJSON* triggering_event,
const char* relay_url); const char* relay_url);
@@ -918,25 +919,58 @@ static void execute_llm_action(const active_trigger_t* t, cJSON* event, const ch
} }
} }
agent_on_trigger(t->skill_d_tag, t->skill_content, event, relay_url); agent_on_trigger(t->skill_d_tag, t->skill_content, t->tools_policy, event, relay_url);
if (overridden) { if (overridden) {
(void)llm_set_config(&old_cfg); (void)llm_set_config(&old_cfg);
} }
} }
/* Return 1 if this event id has already been fired by this trigger. */
static int trigger_seen_event_id_locked(const active_trigger_t* t, const char* event_id) {
if (!t || !event_id || event_id[0] == '\0') return 0;
for (int i = 0; i < t->seen_event_id_count; i++) {
if (strcmp(t->seen_event_ids[i], event_id) == 0) {
return 1;
}
}
return 0;
}
/* Record an event id in the trigger's ring buffer. */
static void trigger_remember_event_id_locked(active_trigger_t* t, const char* event_id) {
if (!t || !event_id || event_id[0] == '\0') return;
snprintf(t->seen_event_ids[t->seen_event_id_next],
TRIGGER_EVENT_ID_LEN,
"%s",
event_id);
t->seen_event_id_next = (t->seen_event_id_next + 1) % TRIGGER_SEEN_EVENT_IDS;
if (t->seen_event_id_count < TRIGGER_SEEN_EVENT_IDS) {
t->seen_event_id_count++;
}
}
static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* event, const char* relay_url) { static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* event, const char* relay_url) {
active_trigger_t* t = &mgr->triggers[index]; active_trigger_t* t = &mgr->triggers[index];
if (!t->enabled) { if (!t->enabled) {
return 0; return 0;
} }
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at"); /* Dedupe by event id, not by created_at. Timestamp dedup drops distinct
time_t created_ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : 0; * events that share a second — common when several agents post at once —
if (created_ts > 0 && created_ts <= t->last_seen_created_at) { * which stalls swarms. */
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
const char* event_id_s = (event_id && cJSON_IsString(event_id) && event_id->valuestring)
? event_id->valuestring
: NULL;
if (event_id_s && trigger_seen_event_id_locked(t, event_id_s)) {
return 0; return 0;
} }
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
time_t created_ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : 0;
time_t now = time(NULL); time_t now = time(NULL);
int cooldown = mgr->cfg->triggers.cooldown_seconds; int cooldown = mgr->cfg->triggers.cooldown_seconds;
if (cooldown < 0) cooldown = 0; if (cooldown < 0) cooldown = 0;
@@ -948,6 +982,9 @@ static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* e
if (created_ts > t->last_seen_created_at) { if (created_ts > t->last_seen_created_at) {
t->last_seen_created_at = created_ts; t->last_seen_created_at = created_ts;
} }
if (event_id_s) {
trigger_remember_event_id_locked(t, event_id_s);
}
active_trigger_t trigger_copy = *t; active_trigger_t trigger_copy = *t;
pthread_mutex_unlock(&mgr->mutex); pthread_mutex_unlock(&mgr->mutex);
+10
View File
@@ -14,6 +14,13 @@
#define TRIGGER_SKILL_CONTENT_MAX 4096 #define TRIGGER_SKILL_CONTENT_MAX 4096
#define TRIGGER_FILTER_JSON_MAX 2048 #define TRIGGER_FILTER_JSON_MAX 2048
/* Ring buffer of recently-fired event ids, used to dedupe by event id
* rather than by created_at timestamp. Timestamp dedup drops distinct
* events that share a second (common when several agents post at once),
* which stalls swarms. */
#define TRIGGER_SEEN_EVENT_IDS 64
#define TRIGGER_EVENT_ID_LEN 65
typedef enum { typedef enum {
TRIGGER_ACTION_LLM = 0, TRIGGER_ACTION_LLM = 0,
TRIGGER_ACTION_TEMPLATE = 1 TRIGGER_ACTION_TEMPLATE = 1
@@ -37,6 +44,9 @@ typedef struct {
time_t last_fired; time_t last_fired;
time_t last_seen_created_at; time_t last_seen_created_at;
time_t last_cron_fire; time_t last_cron_fire;
char seen_event_ids[TRIGGER_SEEN_EVENT_IDS][TRIGGER_EVENT_ID_LEN];
int seen_event_id_count;
int seen_event_id_next;
char cron_expr[64]; char cron_expr[64];
char llm_spec[256]; char llm_spec[256];
char tools_policy[256]; char tools_policy[256];
+8
View File
@@ -0,0 +1,8 @@
# Swarm experiment secrets and runtime state — never commit
keys.env
*.nsec
genesis.jsonc
*.log
*.pid
tasks.json
context.log*
+92
View File
@@ -0,0 +1,92 @@
# Swarm
A swarm is a queen agent plus worker agents that coordinate over Nostr to
solve a problem the admin posts.
- `queen/` — the queen. Watches the admin's kind 1 posts; when addressed with
the phrase **"my queen"** (case-insensitive) she seeds a swarm.
- `worker1/`, `worker2/` — workers that pick up subtasks.
- `admin_post.sh` — publish a kind 1 note as the admin (signed by the n_signer).
Each agent runs from its own directory with its own `genesis.jsonc` and API
port:
| Agent | Port | Config |
|---------|------|---------------------------|
| queen | 8484 | `queen/genesis.jsonc` |
| worker1 | 8485 | `worker1/genesis.jsonc` |
| worker2 | 8486 | `worker2/genesis.jsonc` |
> **Genesis is consumed once.** After first run, all agent state (skills,
> profile, relay list) lives on the relay. To change an agent, edit the events
> on the relay — not `genesis.jsonc`.
## Running the queen
The launcher runs the binary in the foreground, so output streams to your
terminal:
```bash
./swarm/queen/run.sh
```
Verify it is up:
```bash
curl -s http://127.0.0.1:8484/api/status
```
## Following the swarm
`nak` cannot OR two filters in a single REQ (and `-a` + `-p` in one filter is
AND, which returns nothing useful), so run two parallel `nak` streams in one
line. `-a` shows the admin's own posts; `-p` shows every event that tags the
admin (queen/worker replies).
```bash
nak req --stream -k 1 -a 8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e ws://127.0.0.1:7777 & nak req --stream -k 1 -p 8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e ws://127.0.0.1:7777 & wait
```
With labels and readable formatting (requires `jq`):
```bash
nak req --stream -k 1 -a 8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e ws://127.0.0.1:7777 | jq -r '"ORIG \(.created_at) \(.content[0:80]|gsub("\n";" "))"' & nak req --stream -k 1 -p 8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e ws://127.0.0.1:7777 | jq -r '"REPLY \(.created_at) \(.content[0:80]|gsub("\n";" "))"' & wait
```
Notes:
- `--stream` replays existing history first, then stays live. To start from
*now* only, add `-s $(date +%s)` to each.
- The admin hex `8ff74724…` is the decoded form of
`npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks`. `nak`
accepts the npub too, but hex is unambiguous.
- This shows the queen's `root` and `claim` events (both tag the admin with
`p`), so you see the full swarm activity.
## Why the web feed misses replies
`feed.html` (in `client_local`) is a feed, not a thread viewer. It fetches
exactly one level of replies to each top-level post (`#e: [postIds]`), so
nested replies — e.g. the queen's `claim`, which replies to her own `root`
rather than to the admin's post — are never fetched. Use the `nak` streams
above to follow the full thread.
## Managing the relay list
The relay list is stored on Nostr as replaceable events signed by the agent's
key: kind `10002` (NIP-65 relay list) and kind `10050` (DM relays). To change
it, publish new events — editing `genesis.jsonc` only matters on re-bootstrap.
Add a relay to the queen (replace `<QUEEN_NSEC>` and the relay URL):
```bash
nak event --sec <QUEEN_NSEC> -k 10002 -t r=ws://127.0.0.1:7777 -t r=wss://example.net/relay ws://127.0.0.1:7777
nak event --sec <QUEEN_NSEC> -k 10050 -t relay=ws://127.0.0.1:7777 -t relay=wss://example.net/relay ws://127.0.0.1:7777
```
Read the current list back:
```bash
nak req -k 10002 -a <QUEEN_HEX> ws://127.0.0.1:7777
nak req -k 10050 -a <QUEEN_HEX> ws://127.0.0.1:7777
```
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Publish a kind 1 note as the admin, signed by the n_signer on the
# nostr_signer qube (role "main", path m/44'/1237'/0'/0/0).
#
# The admin key never leaves the signer qube.
#
# Usage:
# ./admin_post.sh "My queen, what is 2 + 2?"
# ./admin_post.sh --tag t=didactyl-swarm "some note"
set -euo pipefail
SIGNER_CLIENT="${SIGNER_CLIENT:-$HOME/lt/signer/target/release/signer-client}"
QREXEC="nostr_signer:qubes.SignerRpc"
RELAY="${SWARM_RELAY:-ws://127.0.0.1:7777}"
ROLE="main"
ROLE_PATH="m/44'/1237'/0'/0/0"
# Parse optional --tag k=v flags (repeatable), then the content.
TAGS_JSON="[]"
while [[ $# -gt 0 && "$1" == --tag ]]; do
kv="$2"; shift 2
key="${kv%%=*}"; val="${kv#*=}"
TAGS_JSON=$(TAGS_JSON="$TAGS_JSON" KEY="$key" VAL="$val" python3 -c "
import json, os
tags = json.loads(os.environ['TAGS_JSON'])
tags.append([os.environ['KEY'], os.environ['VAL']])
print(json.dumps(tags))
")
done
CONTENT="${1:-}"
if [[ -z "$CONTENT" ]]; then
echo "usage: $0 [--tag k=v ...] \"content\"" >&2
exit 2
fi
PUBKEY=$("$SIGNER_CLIENT" --qrexec "$QREXEC" --role "$ROLE" --path "$ROLE_PATH" get-public-key)
CREATED_AT=$(date +%s)
EVENT=$(CONTENT="$CONTENT" TAGS_JSON="$TAGS_JSON" PUBKEY="$PUBKEY" CREATED_AT="$CREATED_AT" python3 -c "
import json, os
print(json.dumps({
'kind': 1,
'content': os.environ['CONTENT'],
'tags': json.loads(os.environ['TAGS_JSON']),
'created_at': int(os.environ['CREATED_AT']),
'pubkey': os.environ['PUBKEY'],
}))
")
echo "signing as admin ($PUBKEY) via $QREXEC ..." >&2
SIGNED=$(echo "$EVENT" | "$SIGNER_CLIENT" --qrexec "$QREXEC" --role "$ROLE" --path "$ROLE_PATH" sign-event)
echo "publishing to $RELAY ..." >&2
echo "$SIGNED" | nak event "$RELAY" >/dev/null
echo "$SIGNED"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Run the swarm queen from its own directory.
#
# The genesis file is consumed ONCE on first run. After that, all agent
# state lives on the relay. To change the queen, edit the events on the
# relay (skills, profile, relay list), not genesis.jsonc.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
BIN="${DIDACTYL_BIN:-$ROOT/didactyl_static_x86_64_debug}"
if [[ ! -x "$BIN" ]]; then
echo "didactyl binary not found at $BIN" >&2
echo "Build it with: ./build_static.sh --debug" >&2
exit 1
fi
cd "$HERE"
exec "$BIN" \
--config "$HERE/genesis.jsonc" \
--api-port 8484 \
--api-bind 127.0.0.1 \
--debug 4
+26
View File
@@ -0,0 +1,26 @@
## Queen
You are the queen of a swarm. You watch your administrator's public notes. When the admin addresses you, you seed a swarm.
### Addressing
You only respond when the admin's post contains the phrase "my queen" (case-insensitive). If the phrase is not present, do nothing — stop immediately and call no tools.
### Always seed a swarm
When addressed, you ALWAYS seed a swarm. Do not answer the question yourself, even if it looks simple. The swarm is the answer. Your job is to decompose the problem and let workers solve it.
### How to seed
1. Read the admin's post carefully.
2. Decompose the problem into independent subtasks. Give each subtask a stable id: `subtask-1`, `subtask-2`, and so on. If the problem is a single atomic question, make it one subtask.
3. Call `swarm_create` with a clear title, the problem statement, and `reply_to` set to the admin's post event id. Anchoring to the admin's post keeps the whole swarm in one thread under that post. List the subtasks with their ids in the problem text so workers know the plan.
4. Post a `claim` on the thread describing the decomposition you propose. Use the thread root id (the admin's post id) as the `thread` argument. Include the subtask ids so workers can claim them.
5. Stop. Workers will pick up the subtasks.
### Rules
- You have no authority. You propose; workers decide.
- Keep the thread root focused on the problem, not on you.
- Never compute the answer yourself. Never call `local_shell_exec`. Your only tools are the swarm tools.
- If a worker posts a result, you may post a `synthesis` when all subtasks are done.
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Run worker1 from its own directory.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
BIN="${DIDACTYL_BIN:-$ROOT/didactyl_static_x86_64_debug}"
cd "$HERE"
exec "$BIN" --config "$HERE/genesis.jsonc" --api-port 8485 --api-bind 127.0.0.1 --debug 4
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Run worker2 from its own directory.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
BIN="${DIDACTYL_BIN:-$ROOT/didactyl_static_x86_64_debug}"
cd "$HERE"
exec "$BIN" --config "$HERE/genesis.jsonc" --api-port 8486 --api-bind 127.0.0.1 --debug 4