v0.0.21 - Add full skill_* tool family with schema, execution, dispatch, and README updates

This commit is contained in:
Your Name
2026-03-01 18:55:04 -04:00
parent fea0fdf5c9
commit a446f25400
5 changed files with 1814 additions and 11 deletions

View File

@@ -51,11 +51,11 @@ Agents learn capabilities through skills — Nostr events that any agent can di
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.0.20
## Current Status — v0.0.21
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.20 — Add Tier2/Tier3 Nostr tools: nip05 lookup, encode/decode, dm send, relay info, nip44 encrypt/decrypt, nip17 dm, list manage
> Last release update: v0.0.21 — Add full skill_* tool family with schema, execution, dispatch, and README updates
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
@@ -276,15 +276,43 @@ Every serialized LLM context payload is appended to [`context.log`](context.log)
## Tooling Interface
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:72):
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:881):
- `nostr_post`
- `nostr_query`
- `shell_exec`
- `file_read`
- `file_write`
- Nostr publish/query:
- `nostr_post`
- `nostr_post_readme`
- `nostr_query`
- Nostr interaction and moderation:
- `nostr_delete`
- `nostr_react`
- `nostr_profile_get`
- `nostr_relay_status`
- `nostr_relay_info`
- `nostr_nip05_lookup`
- Nostr encode/decode + encryption/DM:
- `nostr_encode`
- `nostr_decode`
- `nostr_encrypt`
- `nostr_decrypt`
- `nostr_dm_send`
- `nostr_dm_send_nip17`
- Nostr list management:
- `nostr_list_manage`
- Skill management:
- `skill_create`
- `skill_list`
- `skill_adopt`
- `skill_remove`
- `skill_search`
- Local/host tools:
- `shell_exec`
- `file_read`
- `file_write`
- `http_fetch`
- Agent metadata:
- `my_version`
Execution entrypoint: [`tools_execute()`](src/tools.c:434).
Execution entrypoint: [`tools_execute()`](src/tools.c:3765).
## Project Structure

337
plans/skill_tools.md Normal file
View File

@@ -0,0 +1,337 @@
# Skill Tools — Architecture Plan
## Overview
Add a family of five skill-management tools to Didactyl so the agent can **create, list, adopt, remove, and discover** skills at runtime — all through the existing LLM tool-calling loop.
Skills are Nostr events. The tools are thin orchestration wrappers over the existing `nostr_handler_publish_kind_event()` and `nostr_handler_query_json()` primitives.
---
## Nostr Kind Reference
| Kind | Purpose | Replaceable? | Key tag |
|---|---|---|---|
| `31123` | Public skill definition | Yes (d-tag) | `d=<slug>` |
| `31124` | Private skill definition | Yes (d-tag) | `d=<slug>` |
| `10123` | Public skill adoption list | Yes (replaceable) | `a` refs to 31123 events |
All skill events carry these standard tags:
- `["d", "<slug>"]` — unique identifier within the author's pubkey
- `["app", "didactyl"]` — app namespace
- `["scope", "public"]` or `["scope", "private"]`
---
## Tool Family
### 1. `skill_create`
**Purpose:** Create or update a skill definition and optionally auto-adopt it.
**OpenAI schema:**
```json
{
"name": "skill_create",
"description": "Create or update a skill definition (kind 31123 public / 31124 private) and optionally auto-adopt it",
"parameters": {
"type": "object",
"properties": {
"slug": { "type": "string", "description": "Unique skill identifier (lowercase, hyphens allowed)" },
"content": { "type": "string", "description": "Skill body — markdown instructions or structured JSON" },
"scope": { "type": "string", "description": "public (kind 31123) or private (kind 31124). Default: public" },
"description": { "type": "string", "description": "Short one-line description for the skill" },
"auto_adopt": { "type": "boolean", "description": "Automatically add to adoption list (kind 10123). Default: true" }
},
"required": ["slug", "content"]
}
}
```
**Execution logic (`execute_skill_create`):**
1. Validate `slug` — must be non-empty, lowercase alphanumeric + hyphens, no spaces
2. Determine kind: `31123` if scope is `"public"` or absent; `31124` if `"private"`
3. Build tags array:
- `["d", slug]`
- `["app", "didactyl"]`
- `["scope", scope]`
- `["description", description]` if provided
4. Call `nostr_handler_publish_kind_event(kind, content, tags, &result)`
5. If `auto_adopt` is true (default), update the kind `10123` adoption list:
- Query existing `10123` event for own pubkey (same pattern as `execute_nostr_list_manage`)
- Add `["a", "31123:<own_pubkey>:<slug>"]` tag if not already present
- Republish the updated `10123` event
6. Return JSON with `success`, `event_id`, `naddr_uri`, `slug`, `adopted`
**Key design decisions:**
- Auto-adopt defaults to `true` — creating a skill you don't adopt is unusual
- Private skills (31124) are NOT added to the public adoption list (10123)
- Republishing with the same slug replaces the previous version (replaceable event)
---
### 2. `skill_list`
**Purpose:** List the agent's own published skills.
**OpenAI schema:**
```json
{
"name": "skill_list",
"description": "List skills published by this agent, optionally filtered by scope",
"parameters": {
"type": "object",
"properties": {
"scope": { "type": "string", "description": "Filter by public or private. Omit for both." }
}
}
}
```
**Execution logic (`execute_skill_list`):**
1. Build filter based on scope:
- Both: `{"kinds": [31123, 31124], "authors": [own_pubkey]}`
- Public only: `{"kinds": [31123], "authors": [own_pubkey]}`
- Private only: `{"kinds": [31124], "authors": [own_pubkey]}`
2. Call `nostr_handler_query_json(filter, 8000)`
3. Parse results, extract for each event:
- `slug` (from d-tag)
- `kind`
- `scope` (from scope tag)
- `description` (from description tag, if present)
- `created_at` timestamp
- `content` preview (first 200 chars)
4. Return JSON array of skill summaries
---
### 3. `skill_adopt`
**Purpose:** Adopt a skill published by another author (or self) into the agent's adoption list.
**OpenAI schema:**
```json
{
"name": "skill_adopt",
"description": "Add a skill to the agent's public adoption list (kind 10123)",
"parameters": {
"type": "object",
"properties": {
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author" },
"slug": { "type": "string", "description": "Skill slug (d-tag value)" },
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
},
"required": ["pubkey", "slug"]
}
}
```
**Execution logic (`execute_skill_adopt`):**
1. Validate pubkey (64-char hex) and slug (non-empty)
2. Default kind to 31123 if not provided
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
4. Query existing kind `10123` event for own pubkey
5. Check if `["a", "<kind>:<pubkey>:<slug>"]` already exists — if so, return success with `already_adopted: true`
6. Add the tag, republish `10123`
7. Return JSON with `success`, `adopted_address`, `event_id`
---
### 4. `skill_remove`
**Purpose:** Remove a skill from the agent's adoption list.
**OpenAI schema:**
```json
{
"name": "skill_remove",
"description": "Remove a skill from the agent's public adoption list (kind 10123)",
"parameters": {
"type": "object",
"properties": {
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author. Defaults to own pubkey." },
"slug": { "type": "string", "description": "Skill slug to remove" },
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
},
"required": ["slug"]
}
}
```
**Execution logic (`execute_skill_remove`):**
1. Default pubkey to own pubkey if not provided
2. Default kind to 31123
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
4. Query existing kind `10123` event for own pubkey
5. Find and remove matching `["a", ...]` tag
6. Republish `10123`
7. Return JSON with `success`, `removed_address`, `event_id`
---
### 5. `skill_search`
**Purpose:** Search for skills across the agent's Web of Trust.
**OpenAI schema:**
```json
{
"name": "skill_search",
"description": "Search for skills adopted by Web of Trust contacts, or query public skill definitions",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Optional keyword to filter skill slugs or descriptions" },
"pubkey": { "type": "string", "description": "Search skills by a specific author pubkey" },
"popular": { "type": "boolean", "description": "If true, query WoT adoption lists to find most-adopted skills" }
}
}
}
```
**Execution logic (`execute_skill_search`):**
1. If `popular` is true:
- Query `{"kinds": [10123]}` from relays (with reasonable limit)
- Parse all `a`-tags from results
- Count occurrences of each `a`-tag address
- Sort by adoption count descending
- Return top N skill addresses with counts
2. If `pubkey` is provided:
- Query `{"kinds": [31123], "authors": [pubkey]}`
- Return skill summaries
3. If `query` is provided (keyword search):
- Query `{"kinds": [31123]}` with limit
- Filter results client-side by matching `query` against slug, description tag, or content
- Return matching skill summaries
4. Default (no params): return own adopted skills from `10123`
---
## Implementation Architecture
### Shared helper: adoption list update
Since `skill_create`, `skill_adopt`, and `skill_remove` all modify the kind `10123` list, extract a shared helper:
```c
// Fetch current 10123 event, return duplicated tags array (or empty array if none exists)
static cJSON* fetch_adoption_list_tags(tools_context_t* ctx);
// Publish updated 10123 event with new tags
static int publish_adoption_list(tools_context_t* ctx, cJSON* tags, nostr_publish_result_t* result);
```
This is essentially the same pattern already used in `execute_nostr_list_manage()` but specialized for kind `10123`.
### Shared helper: skill summary extraction
```c
// Extract slug, kind, scope, description, created_at from a skill event JSON
static cJSON* extract_skill_summary(cJSON* event);
```
### Flow diagram
```mermaid
flowchart TD
SC[skill_create] --> PUB[nostr_handler_publish_kind_event]
SC --> ADOPT_HELPER[update adoption list helper]
SL[skill_list] --> QUERY[nostr_handler_query_json]
SL --> SUMMARY[extract_skill_summary]
SA[skill_adopt] --> ADOPT_HELPER
SR[skill_remove] --> ADOPT_HELPER
SS[skill_search] --> QUERY
SS --> SUMMARY
ADOPT_HELPER --> QUERY
ADOPT_HELPER --> PUB
```
---
## Changes Required
### `src/tools.c`
1. **Schema registration** — Add 5 new tool definitions in `tools_build_openai_schema_json()` (t22t26)
2. **Execution functions** — Add 5 new `execute_skill_*()` static functions
3. **Dispatch** — Add 5 new `strcmp` branches in `tools_execute()`
4. **Shared helpers** — Add `fetch_adoption_list_tags()`, `publish_adoption_list()`, `extract_skill_summary()`, and `validate_skill_slug()`
### `README.md`
1. Add skill tools to the Tooling Interface section under a new "Skill management" category
### No changes needed to:
- `src/tools.h` — the `tools_context_t` already has `cfg` which provides `keys.public_key_hex`
- `src/nostr_handler.h` — all needed APIs already exist
- `src/config.h` — no new config fields needed
---
## Slug Validation Rules
A valid skill slug must:
- Be 164 characters
- Contain only lowercase letters, digits, and hyphens
- Not start or end with a hyphen
- Not contain consecutive hyphens
```c
static int validate_skill_slug(const char* slug) {
if (!slug || slug[0] == '\0' || strlen(slug) > 64) return 0;
if (slug[0] == '-') return 0;
int prev_dash = 0;
for (size_t i = 0; slug[i]; i++) {
char c = slug[i];
if (c == '-') {
if (prev_dash) return 0;
prev_dash = 1;
} else if (islower(c) || isdigit(c)) {
prev_dash = 0;
} else {
return 0;
}
}
if (slug[strlen(slug) - 1] == '-') return 0;
return 1;
}
```
---
## Implementation Order
1. **Shared helpers**`validate_skill_slug`, `fetch_adoption_list_tags`, `publish_adoption_list`, `extract_skill_summary`
2. **`skill_create`** — most important tool, enables the agent to author skills
3. **`skill_list`** — lets the agent see what it has published
4. **`skill_adopt`** — adopt skills from other authors
5. **`skill_remove`** — remove skills from adoption list
6. **`skill_search`** — discover skills across WoT
7. **Schema registration** — add all 5 tools to `tools_build_openai_schema_json()`
8. **Dispatch wiring** — add all 5 to `tools_execute()`
9. **README update** — document the new tools
10. **Build and test** — verify compilation and basic tool execution
---
## Security Considerations
- **Admin-only**: Skill tools inherit the existing ADMIN tier restriction — only the admin can trigger tool calls
- **Slug validation**: Prevents injection of malformed d-tags
- **No arbitrary kind**: `skill_create` only publishes kind 31123 or 31124, not arbitrary kinds
- **Adoption list integrity**: The helpers always fetch-then-update to avoid clobbering existing adoption entries
- **Content size**: No explicit limit on skill content size — relies on relay limits and LLM context window constraints

139
plans/tool_testing.md Normal file
View File

@@ -0,0 +1,139 @@
# Didactyl Tool Testing Plan
## Overview
Testing agent-mediated tools is different from unit testing pure functions because the execution path spans multiple boundaries:
```
LLM schema interpretation → JSON argument generation → argument parsing → business logic → Nostr/network I/O → JSON response → LLM interpretation
```
This plan defines three testing layers, ordered by implementation priority.
---
## Layer 1: Direct Tool Execution
Call `tools_execute()` directly with known JSON arguments and assert the JSON response structure.
### Implementation
Add a `--test-tool <name> <args_json>` CLI flag to `src/main.c` that:
1. Initializes config and nostr_handler (for network-dependent tools)
2. Calls `tools_execute(&ctx, name, args_json)`
3. Prints the raw JSON result to stdout
4. Exits with 0 if `success: true`, 1 otherwise
### Pure Computation Tools (no network needed)
| Tool | Test Command | Expected |
|------|-------------|----------|
| `nostr_encode` | `--test-tool nostr_encode '{"type":"npub","hex":"<64-char-hex>"}'` | `success: true`, uri starts with `nostr:npub1` |
| `nostr_decode` | `--test-tool nostr_decode '{"uri":"npub1..."}'` | `success: true`, pubkey is 64-char hex |
| `nostr_encrypt` | `--test-tool nostr_encrypt '{"recipient_pubkey":"<hex>","plaintext":"hello"}'` | `success: true`, ciphertext is base64 |
| `nostr_decrypt` | `--test-tool nostr_decrypt '{"sender_pubkey":"<hex>","ciphertext":"<from encrypt>"}'` | `success: true`, plaintext is "hello" |
### Network-Dependent Tools (need relay or HTTP)
| Tool | Test Command | Expected |
|------|-------------|----------|
| `nostr_nip05_lookup` | `--test-tool nostr_nip05_lookup '{"identifier":"_@laantungir.com"}'` | `success: true`, pubkey returned |
| `nostr_relay_info` | `--test-tool nostr_relay_info '{"relay_url":"wss://relay.damus.io"}'` | `success: true`, info.basic.name present |
| `nostr_relay_status` | `--test-tool nostr_relay_status '{}'` | `success: true`, relay_count > 0 |
| `nostr_dm_send` | `--test-tool nostr_dm_send '{"recipient_pubkey":"<hex>","message":"test"}'` | `success: true` |
| `nostr_post` | `--test-tool nostr_post '{"kind":1,"content":"test"}'` | `success: true`, event_id present |
| `nostr_delete` | `--test-tool nostr_delete '{"event_ids":["<64-char-hex>"]}'` | `success: true` |
| `nostr_react` | `--test-tool nostr_react '{"event_id":"<hex>","event_pubkey":"<hex>"}'` | `success: true` |
| `nostr_profile_get` | `--test-tool nostr_profile_get '{"pubkey":"<hex>"}'` | `success: true`, found: true/false |
| `nostr_dm_send_nip17` | `--test-tool nostr_dm_send_nip17 '{"recipient_pubkey":"<hex>","message":"test"}'` | `success: true` |
| `nostr_list_manage` | `--test-tool nostr_list_manage '{"list_kind":10000,"action":"add","items":[["p","<hex>"]]}'` | `success: true` |
### Error Case Tests
Each tool should also be tested with:
- Empty args: `'{}'` — should return descriptive error
- Missing required fields — should return specific error message
- Invalid hex strings — should reject gracefully
- Double-encoded JSON string args — should parse correctly (regression for the `invalid arguments JSON` bug)
---
## Layer 2: Schema-Parse Fidelity
Validates that the OpenAI function schema matches what executors actually accept.
### Implementation
A Python script (`tests/validate_schemas.py`) that:
1. Runs `didactyl_static --dump-schemas` (new flag) to get the tool schema JSON
2. For each tool in the schema:
- Generates a minimal valid argument payload from `required` + `properties`
- Runs `didactyl_static --test-tool <name> '<generated_json>'`
- Asserts exit code 0 or expected network error
3. Reports mismatches between schema and executor expectations
### What This Catches
- Schema says `required: ["hex"]` but executor checks for `"pubkey"` — mismatch
- Schema says `type: "integer"` but executor reads it as string
- Schema advertises parameters the executor ignores
- Missing required parameters in schema that executor demands
---
## Layer 3: Agent Integration Testing (Live DM)
The most natural test — DM the running agent and verify it uses tools correctly.
### Prerequisites
- Local relay running at `ws://127.0.0.1:7777`
- Didactyl running with valid config
- A separate Nostr client (or script) to send/receive DMs
### Test Prompts
| # | Tool | DM Prompt | Assert in Response |
|---|------|-----------|-------------------|
| 1 | `nostr_encode` | "Encode this pubkey as npub: `<64-char hex>`" | Contains `nostr:npub1` |
| 2 | `nostr_decode` | "Decode this npub: `npub1...`" | Contains the hex pubkey |
| 3 | `nostr_dm_send` | "Send a DM to `<your-own-pubkey>` saying 'hello test'" | Confirms sent; you receive it |
| 4 | `nostr_encrypt` | "Encrypt 'secret message' for `<pubkey>`" | Contains base64 ciphertext |
| 5 | `nostr_decrypt` | "Decrypt this NIP-44 payload: `<ciphertext from #4>`" | Contains 'secret message' |
| 6 | `nostr_nip05_lookup` | "Look up `_@laantungir.com`" | Returns a pubkey |
| 7 | `nostr_relay_info` | "Get NIP-11 info for `wss://relay.damus.io`" | Returns relay name and supported NIPs |
| 8 | `nostr_relay_status` | "Show me relay connection status" | Lists connected relays with stats |
| 9 | `nostr_react` | "React with 🤙 to event `<id>` from `<pubkey>`" | Confirms kind 7 published |
| 10 | `nostr_delete` | "Delete event `<id>`" | Confirms kind 5 published |
| 11 | `nostr_profile_get` | "Look up the profile for `<pubkey>`" | Returns name/about/picture |
| 12 | `nostr_post` | "Post a kind 1 note saying 'tool test'" | Confirms published with event_id |
| 13 | `nostr_list_manage` | "Add `<pubkey>` to my mute list" | Confirms kind 10000 published |
| 14 | `nostr_dm_send_nip17` | "Send a private NIP-17 DM to `<pubkey>` saying 'gift wrap test'" | Confirms gift wrap sent |
| 15 | `nostr_post_readme` | "Publish the README to Nostr" | Confirms kind 30023 with d=readme.md |
### Verification Methods
- **stdout logs**: Watch `[didactyl] executing tool call: <name>` in terminal
- **context.log**: Full LLM conversation including tool calls and results
- **Relay inspection**: Query the local relay for published events
- **DM receipt**: For DM tools, verify the message arrives at the recipient
---
## Implementation Priority
1. **Immediate (no code changes)**: Run Layer 3 tests by DMing the live agent
2. **Next sprint**: Add `--test-tool` CLI flag for Layer 1
3. **Later**: Add `--dump-schemas` flag and Python schema validator for Layer 2
4. **CI integration**: Wrap Layer 1 pure-computation tests in a shell script that runs after `build_static.sh`
---
## Local Relay Setup for Testing
A local relay like `strfry` or `nostr-rs-relay` at `ws://127.0.0.1:7777` provides:
- All published events are observable and queryable
- No rate limits or content policies
- NIP-42 auth testing in isolation
- Gift-wrapped NIP-17 DMs stay in your test environment
- Database can be wiped between test runs

View File

@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 0
#define DIDACTYL_VERSION_PATCH 20
#define DIDACTYL_VERSION "v0.0.20"
#define DIDACTYL_VERSION_PATCH 21
#define DIDACTYL_VERSION "v0.0.21"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"

File diff suppressed because it is too large Load Diff