Files
didactyl/plans/prompt_templates_coding.md
T

396 lines
13 KiB
Markdown

# Prompt Template System — Coding Plan
Design doc: `plans/prompt_templates.md`
## Overview
Replace the hardcoded context assembly in `src/agent.c` with a template-driven system. The template lives inside the soul event content (kind 31120), delimited by `---template---`. If no template marker is found, fall back to the current hardcoded assembly for backward compatibility.
---
## Phase 1: New Files — Template Parser & Builder
### Step 1.1: Create `src/prompt_template.h`
Header with data structures and public API.
```c
#ifndef DIDACTYL_PROMPT_TEMPLATE_H
#define DIDACTYL_PROMPT_TEMPLATE_H
#include "cjson/cJSON.h"
#include "config.h"
#define PROMPT_TEMPLATE_MAX_SECTIONS 32
#define PROMPT_TEMPLATE_MAX_NAME_LEN 64
#define PROMPT_TEMPLATE_MAX_ROLE_LEN 16
#define PROMPT_TEMPLATE_MARKER "---template---"
typedef struct {
char name[PROMPT_TEMPLATE_MAX_NAME_LEN];
char role[PROMPT_TEMPLATE_MAX_ROLE_LEN]; // system, user, assistant, expand
char* content_template; // content with {{var}} placeholders, or NULL
int limit; // for expand sections, 0 = default
} prompt_template_section_t;
typedef struct {
char* personality; // everything above ---template---
prompt_template_section_t sections[PROMPT_TEMPLATE_MAX_SECTIONS];
int section_count;
} prompt_template_t;
// Variable resolver callback: given a variable name, return a malloc'd string or NULL.
// The caller frees the returned string.
typedef char* (*prompt_var_resolver_fn)(const char* var_name, void* user_data);
// Parse soul content into a template. Returns 0 on success, -1 if no template found.
// On success, caller must call prompt_template_free() when done.
// On -1 (no template), out_template is zeroed — caller should use hardcoded fallback.
int prompt_template_parse(const char* soul_content, prompt_template_t* out_template);
// Build a cJSON messages array from a parsed template.
// resolver_fn is called for each {{variable}} encountered.
// dm_history_messages is a cJSON array of user/assistant messages for "expand" sections.
// Returns a new cJSON array (caller owns it), or NULL on error.
cJSON* prompt_template_build_messages(
const prompt_template_t* tmpl,
prompt_var_resolver_fn resolver_fn,
void* resolver_user_data,
cJSON* dm_history_messages
);
// Free internals of a parsed template (does not free the struct itself).
void prompt_template_free(prompt_template_t* tmpl);
// Get the section name for a message index (for logging/API).
// Returns the section name string or NULL if idx is out of range.
const char* prompt_template_section_name_at(const prompt_template_t* tmpl, int section_idx);
#endif
```
### Step 1.2: Create `src/prompt_template.c`
Implementation file with three main components:
#### 1.2a: Template Parser — `prompt_template_parse()`
Logic:
1. Search `soul_content` for the string `"\n---template---\n"` (with newlines on both sides, or at start/end of string).
2. If not found, return -1 (no template).
3. Split: everything before the marker → `tmpl->personality` (strdup'd).
4. Everything after the marker → parse as template sections.
5. Template section format is line-oriented YAML-like:
```
- section: admin_identity
role: system
limit: 0
content: |
This is your administrator! Admin pubkey: {{admin_pubkey}}
```
Parsing rules:
- Lines starting with `- section:` begin a new section.
- `role:` sets the role (default: `system`).
- `limit:` sets the limit integer (default: 0).
- `content: |` starts a multi-line content block. All subsequent lines indented by 4+ spaces (or until the next `- section:` line) are the content template.
- `{{variable_name}}` placeholders in content are left as-is during parsing; they are resolved at build time.
Edge cases:
- Trim leading/trailing whitespace from section names and roles.
- If `content:` is a single line (not `|`), treat the rest of the line as the content.
- Cap at `PROMPT_TEMPLATE_MAX_SECTIONS`.
#### 1.2b: Variable Resolver — `prompt_template_build_messages()`
Logic:
1. Create a new cJSON array.
2. First, append the personality as a system message (role=system, content=personality).
3. For each section in order:
- If `role` is `"expand"`: insert the `dm_history_messages` array items here, limited by `section.limit` (take last N if limit > 0).
- Otherwise: resolve `{{var}}` placeholders in `content_template` by calling `resolver_fn(var_name, user_data)`. Build the resolved string. Append as a message with the configured role.
4. Return the array.
Variable resolution:
- Scan content_template for `{{` ... `}}` pairs.
- Extract the variable name (trimmed).
- Call `resolver_fn(name, user_data)`.
- If resolver returns NULL, substitute empty string.
- If resolver returns a string, substitute it and free the returned string.
- Build the final resolved content by concatenating literal segments and resolved values.
#### 1.2c: Cleanup — `prompt_template_free()`
- Free `tmpl->personality`.
- For each section, free `content_template`.
- Zero the struct.
---
## Phase 2: Variable Resolver in `src/agent.c`
### Step 2.1: Create a resolver function
Add a static function in `src/agent.c`:
```c
static char* agent_resolve_template_var(const char* var_name, void* user_data);
```
This function maps variable names to data sources:
| Variable Name | Source | Implementation |
|---|---|---|
| `admin_pubkey` | `g_cfg->admin.pubkey` | `strdup(g_cfg->admin.pubkey)` |
| `admin_kind0_json` | `nostr_handler_get_admin_kind0_context()` | Already returns malloc'd string |
| `admin_kind10002_json` | `nostr_handler_get_admin_kind10002_context()` | Already returns malloc'd string |
| `startup_events_json` | Serialize startup events | Reuse logic from current `append_startup_events_context()` |
| `adopted_skills_content` | Build skills string | Reuse logic from current `append_adopted_skills_context()` |
| `admin_notes_content` | `nostr_handler_get_admin_kind1_notes_context()` | Already returns malloc'd string |
| `agent_pubkey` | `g_cfg->keys.public_key_hex` | `strdup(g_cfg->keys.public_key_hex)` |
The `user_data` parameter is unused (NULL) since the resolver accesses globals.
### Step 2.2: Extract helper functions from existing code
Refactor the following existing static functions to return malloc'd strings instead of appending directly to a cJSON array:
- Extract startup events serialization from `append_startup_events_context()` (lines 383-434) into a new `static char* build_startup_events_string(void)`.
- Extract adopted skills content from `append_adopted_skills_context()` (lines ~744-940) into a new `static char* build_adopted_skills_string(void)`.
These helpers are called by the resolver function.
---
## Phase 3: Wire Template into Agent
### Step 3.1: Parse template at init time
In `agent_init()` (or when `g_system_context` is set), after the soul content is available:
```c
static prompt_template_t g_prompt_template;
static int g_has_template = 0;
```
After `g_system_context` is assigned, call:
```c
g_has_template = (prompt_template_parse(g_system_context, &g_prompt_template) == 0);
```
If `g_has_template` is true, `g_prompt_template.personality` replaces `g_system_context` for the system prompt message.
### Step 3.2: Modify `agent_build_admin_messages_json()`
Current location: `src/agent.c:1092`
Current signature (unchanged):
```c
int agent_build_admin_messages_json(const char* current_user_message, char** out_messages_json);
```
New logic:
```c
if (g_has_template) {
// Build DM history as a cJSON array
cJSON* dm_history = build_dm_history_array(current_user_message);
// Build messages from template
cJSON* messages = prompt_template_build_messages(
&g_prompt_template,
agent_resolve_template_var,
NULL,
dm_history
);
cJSON_Delete(dm_history);
if (!messages) {
return -1;
}
char* json = cJSON_PrintUnformatted(messages);
cJSON_Delete(messages);
*out_messages_json = json;
return json ? 0 : -1;
} else {
// Existing hardcoded assembly (current code, unchanged)
...
}
```
### Step 3.3: Extract DM history builder
Extract the DM history logic from `append_recent_admin_dm_history()` into a function that returns a cJSON array of user/assistant messages:
```c
static cJSON* build_dm_history_array(const char* current_user_message);
```
This is used by the template builder for `role: expand` sections.
---
## Phase 4: Context Log Formatting
### Step 4.1: Update `format_context_payload_for_log()`
Current location: `src/agent.c:245`
When `g_has_template` is true, use section names from the template instead of `detect_context_section()`:
- Message 0 is always `system_prompt` (the personality).
- Messages 1..N map to template sections by index.
- For `expand` sections, multiple messages share the same section name.
Change the log header from:
```
Message 01 | role=system | section=system_prompt
```
To:
```
Section: system_prompt | role=system
```
### Step 4.2: Update `classify_part_name()` in `src/http_api.c`
Current location: `src/http_api.c:113`
Add a new exported function from `src/agent.h`:
```c
const char* agent_get_section_name_for_message(int message_index);
```
This returns the template section name if a template is active, or falls back to the existing content-prefix detection.
In `classify_part_name()`, call this function first. If it returns non-NULL, use it. Otherwise fall back to the existing `strncmp` chain.
---
## Phase 5: Build System Updates
### Step 5.1: Update `Makefile`
Add `$(SRC_DIR)/prompt_template.c` to the `SRCS` list (after `trigger_manager.c`, before `http_api.c`).
### Step 5.2: Update `Dockerfile.alpine-musl`
Add `src/prompt_template.c` to the gcc command line (after `src/trigger_manager.c`, before `src/http_api.c`).
---
## Phase 6: Default Template in Soul
### Step 6.1: Update `config.json.example`
The startup events should include a soul event (kind 31120) with a `---template---` section that matches the current hardcoded behavior:
```markdown
# Didactyl Agent
You are Didactyl, a sovereign AI agent living on Nostr.
...existing soul content...
---template---
- section: admin_identity
role: system
content: |
This is your administrator! Admin pubkey (hex): {{admin_pubkey}}
- section: admin_profile
role: system
content: |
Administrator profile (JSON): {{admin_kind0_json}}
- section: admin_relay_list
role: system
content: |
Administrator relay list (JSON): {{admin_kind10002_json}}
- section: startup_events
role: system
content: |
Startup events memory (kinds/content/tags): {{startup_events_json}}
- section: adopted_skills
role: system
content: |
{{adopted_skills_content}}
- section: dm_history
role: expand
limit: 12
- section: admin_notes
role: system
limit: 10
content: |
Administrator recent public notes: {{admin_notes_content}}
```
---
## Phase 7: Testing
### Step 7.1: Backward compatibility test
1. Build with `make`.
2. Run with existing config (no `---template---` in soul).
3. Send a DM and verify `context.log` output matches pre-change format.
4. Verify `/api/context/current` returns expected structure.
### Step 7.2: Template test
1. Edit the soul event content to include `---template---` section.
2. Restart agent.
3. Send a DM and verify `context.log` shows section-named headers.
4. Verify `/api/context/current` returns section names from template.
5. Verify the LLM receives the correct messages in the correct order.
### Step 7.3: Section reordering test
1. Move `admin_notes` section above `adopted_skills` in the template.
2. Restart and verify the order changes in `context.log`.
### Step 7.4: Limit test
1. Set `dm_history` limit to 4 (instead of 12).
2. Verify only 4 DM history turns appear in context.
---
## File Change Summary
| File | Action | Description |
|---|---|---|
| `src/prompt_template.h` | **NEW** | Data structures and API |
| `src/prompt_template.c` | **NEW** | Parser, variable resolver, context builder |
| `src/agent.c` | **MODIFY** | Add template globals, resolver function, wire into `agent_build_admin_messages_json()`, update log formatter |
| `src/agent.h` | **MODIFY** | Add `agent_get_section_name_for_message()` export |
| `src/http_api.c` | **MODIFY** | Update `classify_part_name()` to use template section names |
| `Makefile` | **MODIFY** | Add `prompt_template.c` to SRCS |
| `Dockerfile.alpine-musl` | **MODIFY** | Add `prompt_template.c` to gcc command |
| `config.json.example` | **MODIFY** | Update soul event to include template section |
---
## Implementation Order
1. `src/prompt_template.h` — data structures and API declarations
2. `src/prompt_template.c` — parser, builder, free
3. `Makefile` + `Dockerfile.alpine-musl` — add new source file
4. Build and verify compilation
5. `src/agent.c` — extract helper functions (`build_startup_events_string`, `build_adopted_skills_string`, `build_dm_history_array`)
6. `src/agent.c` — add resolver function and template globals
7. `src/agent.c` — wire template into `agent_build_admin_messages_json()`
8. `src/agent.c` — update `format_context_payload_for_log()`
9. `src/agent.h` + `src/http_api.c` — section name API for classify_part_name
10. Build and test backward compatibility (no template in soul)
11. `config.json.example` — add template to soul event
12. Test with template soul
13. Test section reordering and limit changes