Skip to content

Commit 267fcd4

Browse files
idoubiclaude
andcommitted
refactor(prompt): modular system prompt with identity-first ordering
- Extract prompt assembly into prompt_modules.go with per-mode module lists (Agent: 13 modules, Chatbot: 12 modules, Customize: 3 modules) - Move SOUL.md/IDENTITY.md to top of prompt (primacy bias) with identity anchor + tail reinforcement (recency bias) to fix persona amnesia - Chatbot mode: add web_search, web_fetch, exec, load_skill to tool allowlist; add sandbox/skills/chatbot_tools modules; cap tool iterations at 5 (configurable per-agent); block pip/npm install commands - Add FASTCLAW_DEBUG_MODE env var for debug output (prompt dump etc.) - set_timezone: write to USER.md (primary) + database (secondary) so timezone persists across sessions without extra DB lookup - Date line: distinguish explicit vs fallback timezone to prevent model from re-asking timezone every new session - WeChat inbound: handle image messages (download + AES-ECB decrypt from CDN → base64 data URL → vision model), with debug logging Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8e3625c commit 267fcd4

7 files changed

Lines changed: 1144 additions & 775 deletions

File tree

internal/agent/context.go

Lines changed: 30 additions & 743 deletions
Large diffs are not rendered by default.

internal/agent/loop.go

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3066,10 +3066,10 @@ func (a *Agent) RegisteredTools() []tools.ToolInfo {
30663066
// (cron-triggered greetings, multi-recipient broadcasts) should fall
30673067
// back to `agent` mode or write a plugin.
30683068
//
3069-
// Also absent: exec, web_fetch / web_search, scheduling, delegation
3070-
// — all agent-loop machinery that doesn't belong in a chat persona's
3071-
// voice. Add new built-ins here only when they're universally useful
3072-
// for chatbot products; everything else belongs in a plugin.
3069+
// Still absent: scheduling (create_cron_job), delegation (delegate_task),
3070+
// start_app_preview — agent-loop machinery that doesn't belong in a
3071+
// chat persona. Add new built-ins here only when they're universally
3072+
// useful for chatbot products; everything else belongs in a plugin.
30733073
var chatbotBuiltinAllowlist = []string{
30743074
"image_gen",
30753075
"tts",
@@ -3078,12 +3078,15 @@ var chatbotBuiltinAllowlist = []string{
30783078
// set_timezone keeps "their local time" right for chat (greetings,
30793079
// "晚安" timing) — chatbots need it as much as full agents do.
30803080
"set_timezone",
3081-
// Coding-agent preview tools. Only ever REGISTERED when a project
3082-
// runtime is wired (SetProjectRuntime), so listing them here is a
3083-
// harmless no-op for ordinary chat personas and makes the preview
3084-
// usable regardless of the agent's prompt mode.
3085-
"start_app_preview",
3086-
"app_preview_logs",
3081+
// Web tools let the chatbot answer real-time questions (weather,
3082+
// news, prices, etc.) without requiring full agent mode.
3083+
"web_search",
3084+
"web_fetch",
3085+
// exec + load_skill let the chatbot invoke installed skills
3086+
// (e.g. image generation, data lookup). Skills are the primary
3087+
// extension mechanism — without exec the chatbot can't run them.
3088+
"exec",
3089+
"load_skill",
30873090
}
30883091

30893092
// builtinAllowForMode returns the built-in tool name allowlist for the

internal/agent/prompt_modules.go

Lines changed: 894 additions & 0 deletions
Large diffs are not rendered by default.

internal/agent/tools/timezone.go

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"strings"
78
"time"
89

910
"github.com/fastclaw-ai/fastclaw/internal/scope"
@@ -14,20 +15,21 @@ type setTimezoneArgs struct {
1415
Timezone string `json:"timezone"`
1516
}
1617

17-
// RegisterTimezoneTool registers set_timezone — the structured
18-
// counterpart to "write the chatter's timezone into USER.md". USER.md
19-
// is free text the model may or may not act on; this tool persists the
20-
// timezone where the RUNTIME reads it (scope prefs), so the system
21-
// prompt's date line and cron scheduling switch to the chatter's local
22-
// time deterministically instead of relying on the model doing offset
23-
// arithmetic.
18+
// RegisterTimezoneTool registers set_timezone. The tool persists the
19+
// timezone in TWO places:
2420
//
25-
// The chatter is resolved at execute time via r.ChatterUserID() —
26-
// bindSession stamps it per-turn — so one registration serves every
27-
// sender the agent talks to.
21+
// 1. USER.md — the chatter profile loaded into every system prompt.
22+
// This is the primary path: chatterLocation() reads USER.md first,
23+
// so the date line shows the correct timezone on every new session
24+
// without depending on a database query.
25+
// 2. scope prefs (database) — used by cron scheduling so jobs fire at
26+
// the chatter's local time. This is the secondary path.
27+
//
28+
// Writing to both guarantees the timezone survives across sessions and
29+
// is visible to the model in the system prompt.
2830
func RegisterTimezoneTool(r *Registry, st store.Store) {
2931
r.Register("set_timezone",
30-
"Record the current chatter's timezone. Call this whenever the chatter tells you their timezone, city, or country (e.g. \"我在北京\" → Asia/Shanghai), or when their messages imply one. The runtime uses it to show you their local time and to fire their scheduled tasks at the right local hour — do NOT just note the timezone in USER.md, that does not affect scheduling.",
32+
"Record the current chatter's timezone. Call this whenever the chatter tells you their timezone, city, or country (e.g. \"我在北京\" → Asia/Shanghai). This persists the timezone to the chatter's profile so future sessions use their local time automatically.",
3133
map[string]interface{}{
3234
"type": "object",
3335
"properties": map[string]interface{}{
@@ -51,9 +53,6 @@ func makeSetTimezone(st store.Store, r *Registry) ToolFunc {
5153
if args.Timezone == "" {
5254
return "", fmt.Errorf("timezone is required")
5355
}
54-
// "Local" is technically loadable but meaningless to persist —
55-
// it would pin the chatter to whatever the server's TZ happens
56-
// to be at read time.
5756
if args.Timezone == "Local" {
5857
return "", fmt.Errorf("timezone must be a concrete IANA name like 'Asia/Shanghai', not 'Local'")
5958
}
@@ -65,10 +64,52 @@ func makeSetTimezone(st store.Store, r *Registry) ToolFunc {
6564
if chatterUID == "" {
6665
return "", fmt.Errorf("no chatter identity on this turn — cannot persist timezone")
6766
}
68-
if err := scope.SaveUserTimezone(ctx, st, chatterUID, args.Timezone); err != nil {
69-
return "", fmt.Errorf("save timezone: %w", err)
67+
68+
// 1. Write to USER.md — primary persistence path.
69+
// chatterLocation() reads USER.md first, so this guarantees the
70+
// date line shows the correct timezone on every future session.
71+
if r.systemFileStore != nil {
72+
userMDUID := r.systemFileUserID("USER.md")
73+
upsertUserMDTimezone(ctx, r, userMDUID, args.Timezone)
74+
}
75+
76+
// 2. Write to scope prefs (database) — secondary path for cron.
77+
if st != nil {
78+
_ = scope.SaveUserTimezone(ctx, st, chatterUID, args.Timezone)
7079
}
71-
return fmt.Sprintf("Timezone saved: %s. The chatter's local time is now %s. New scheduled tasks will fire in this timezone; existing ones keep the timezone they were created with.",
80+
81+
return fmt.Sprintf("Timezone saved: %s. The chatter's local time is now %s.",
7282
args.Timezone, time.Now().In(loc).Format("2006-01-02 15:04:05 -0700 (Monday)")), nil
7383
}
7484
}
85+
86+
// upsertUserMDTimezone reads the current USER.md, adds or updates a
87+
// "Timezone: <tz>" line, and writes it back. If USER.md doesn't exist
88+
// yet, it creates a minimal one with just the timezone.
89+
func upsertUserMDTimezone(ctx context.Context, r *Registry, userID, tz string) {
90+
const filename = "USER.md"
91+
const tzPrefix = "- Timezone: "
92+
93+
content := ""
94+
if data, err := r.readSystemFileForUser(ctx, userID, filename); err == nil {
95+
content = strings.TrimSpace(string(data))
96+
}
97+
98+
// Check if there's already a Timezone line and update it.
99+
if idx := strings.Index(content, tzPrefix); idx >= 0 {
100+
// Find end of the line.
101+
end := strings.Index(content[idx:], "\n")
102+
if end < 0 {
103+
end = len(content) - idx
104+
}
105+
content = content[:idx] + tzPrefix + tz + content[idx+end:]
106+
} else if content != "" {
107+
// Append to existing content.
108+
content = content + "\n" + tzPrefix + tz
109+
} else {
110+
// Create new USER.md with timezone.
111+
content = "# Current Chatter\n" + tzPrefix + tz
112+
}
113+
114+
_ = r.systemFileStore.SaveWorkspaceFile(ctx, r.agentID, userID, filename, []byte(content))
115+
}

internal/channels/wechat.go

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -392,12 +392,40 @@ func (w *WeChat) dispatchInbound(m wechatMessage) {
392392
}
393393

394394
var text string
395+
var imageURLs []string
395396
for _, item := range m.ItemList {
396397
switch item.Type {
397398
case wechatItemTypeText:
398399
if item.TextItem != nil && item.TextItem.Text != "" {
399400
text = item.TextItem.Text
400401
}
402+
case wechatItemTypeImage:
403+
if item.ImageItem == nil {
404+
continue
405+
}
406+
// Log the raw image item for debugging — need to understand
407+
// what iLink actually sends before deciding how to process.
408+
slog.Info("wechat inbound image item",
409+
"account", w.accountID, "from", m.FromUserID,
410+
"has_url", item.ImageItem.URL != "",
411+
"url_preview", truncate(item.ImageItem.URL, 80),
412+
"has_media", item.ImageItem.Media != nil,
413+
"encrypt_query_param", truncateMediaParam(item.ImageItem.Media),
414+
"mid_size", item.ImageItem.MidSize)
415+
// Download + decrypt from CDN, encode as data URL.
416+
// iLink CDN URLs are encrypted/auth-only — the model cannot
417+
// fetch them directly. Must download and decrypt first.
418+
if item.ImageItem.Media != nil && item.ImageItem.Media.EncryptQueryParam != "" {
419+
if dataURL, err := w.downloadAndDecryptImage(item.ImageItem); err != nil {
420+
slog.Warn("wechat image download failed",
421+
"account", w.accountID, "from", m.FromUserID, "error", err)
422+
} else {
423+
imageURLs = append(imageURLs, dataURL)
424+
}
425+
} else if item.ImageItem.URL != "" {
426+
// Fallback: try direct URL if no encrypted media present.
427+
imageURLs = append(imageURLs, item.ImageItem.URL)
428+
}
401429
case wechatItemTypeVoice:
402430
// iLink ships speech-to-text transcription alongside the
403431
// audio bytes — use it directly so the agent sees the
@@ -407,15 +435,17 @@ func (w *WeChat) dispatchInbound(m wechatMessage) {
407435
text = item.VoiceItem.Text
408436
}
409437
}
410-
if text != "" {
411-
break
412-
}
413438
}
414-
if text == "" {
439+
if text == "" && len(imageURLs) == 0 {
415440
slog.Debug("wechat skipping unsupported message",
416441
"account", w.accountID, "from", m.FromUserID, "items", len(m.ItemList))
417442
return
418443
}
444+
// Image-only messages: give the model a text cue so it knows to
445+
// describe or act on the image rather than seeing an empty turn.
446+
if text == "" && len(imageURLs) > 0 {
447+
text = "[image]"
448+
}
419449

420450
// iLink doesn't distinguish DM vs group at the protocol level the
421451
// way Telegram does — every message has a from_user_id and a
@@ -442,6 +472,7 @@ func (w *WeChat) dispatchInbound(m wechatMessage) {
442472
UserID: m.FromUserID,
443473
MessageID: strconv.FormatInt(m.MessageID, 10),
444474
Text: text,
475+
PhotoURLs: imageURLs,
445476
PeerKind: "dm",
446477
}
447478
}
@@ -1134,6 +1165,95 @@ func wechatAESECBEncrypt(plaintext, key []byte) ([]byte, error) {
11341165
return encrypted, nil
11351166
}
11361167

1168+
// downloadAndDecryptImage fetches an AES-128-ECB encrypted image from
1169+
// the iLink CDN and returns a base64 data URL the vision model can read.
1170+
func (w *WeChat) downloadAndDecryptImage(img *wechatImageItem) (string, error) {
1171+
if img.Media == nil || img.Media.EncryptQueryParam == "" {
1172+
return "", fmt.Errorf("no media info")
1173+
}
1174+
// Reconstruct the CDN download URL from the encrypt_query_param.
1175+
cdnURL := fmt.Sprintf("%s/download?encrypted_query_param=%s",
1176+
wechatCDNBaseURL, url.QueryEscape(img.Media.EncryptQueryParam))
1177+
1178+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1179+
defer cancel()
1180+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cdnURL, nil)
1181+
if err != nil {
1182+
return "", err
1183+
}
1184+
resp, err := w.httpClient.Do(req)
1185+
if err != nil {
1186+
return "", err
1187+
}
1188+
defer resp.Body.Close()
1189+
if resp.StatusCode != http.StatusOK {
1190+
return "", fmt.Errorf("CDN HTTP %d", resp.StatusCode)
1191+
}
1192+
ciphertext, err := io.ReadAll(resp.Body)
1193+
if err != nil {
1194+
return "", err
1195+
}
1196+
1197+
// Decode the AES key. Wire format: base64(hex_string).
1198+
aesKeyB64 := img.Media.AESKey
1199+
aesKeyHex, err := base64.StdEncoding.DecodeString(aesKeyB64)
1200+
if err != nil {
1201+
return "", fmt.Errorf("decode aes key base64: %w", err)
1202+
}
1203+
aesKey, err := hex.DecodeString(string(aesKeyHex))
1204+
if err != nil {
1205+
return "", fmt.Errorf("decode aes key hex: %w", err)
1206+
}
1207+
1208+
plaintext, err := wechatAESECBDecrypt(ciphertext, aesKey)
1209+
if err != nil {
1210+
return "", fmt.Errorf("decrypt: %w", err)
1211+
}
1212+
1213+
// Detect content type and encode as data URL.
1214+
ct := http.DetectContentType(plaintext)
1215+
b64 := base64.StdEncoding.EncodeToString(plaintext)
1216+
return fmt.Sprintf("data:%s;base64,%s", ct, b64), nil
1217+
}
1218+
1219+
// wechatAESECBDecrypt is the inverse of wechatAESECBEncrypt — PKCS7 unpad.
1220+
func wechatAESECBDecrypt(ciphertext, key []byte) ([]byte, error) {
1221+
block, err := aes.NewCipher(key)
1222+
if err != nil {
1223+
return nil, err
1224+
}
1225+
if len(ciphertext)%aes.BlockSize != 0 {
1226+
return nil, fmt.Errorf("ciphertext not aligned to block size")
1227+
}
1228+
plaintext := make([]byte, len(ciphertext))
1229+
for i := 0; i < len(ciphertext); i += aes.BlockSize {
1230+
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
1231+
}
1232+
// PKCS7 unpad.
1233+
if len(plaintext) == 0 {
1234+
return plaintext, nil
1235+
}
1236+
padLen := int(plaintext[len(plaintext)-1])
1237+
if padLen > aes.BlockSize || padLen == 0 {
1238+
return plaintext, nil // not padded or invalid — return as-is
1239+
}
1240+
return plaintext[:len(plaintext)-padLen], nil
1241+
}
1242+
1243+
func truncate(s string, n int) string {
1244+
if len(s) <= n {
1245+
return s
1246+
}
1247+
return s[:n] + "..."
1248+
}
1249+
1250+
func truncateMediaParam(m *wechatMediaInfo) string {
1251+
if m == nil {
1252+
return ""
1253+
}
1254+
return truncate(m.EncryptQueryParam, 40)
1255+
}
1256+
11371257
func wechatAESECBPaddedSize(plaintextSize int) int {
11381258
return (plaintextSize/aes.BlockSize + 1) * aes.BlockSize
11391259
}

internal/config/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,17 @@ func (cfg *Config) MergedAgentConfig(entry AgentEntry) ResolvedAgent {
857857
}
858858
}
859859

860+
// Chatbot mode: cap tool iterations at a lower default (5) unless
861+
// the operator explicitly set a value on the agent entry. Chatbots
862+
// are conversational — 20 tool rounds burns tokens and makes the
863+
// user wait too long.
864+
if resolved.PromptMode == PromptModeChatbot && entry.MaxToolIterations == 0 {
865+
const chatbotDefaultIter = 5
866+
if resolved.MaxToolIterations > chatbotDefaultIter {
867+
resolved.MaxToolIterations = chatbotDefaultIter
868+
}
869+
}
870+
860871
return resolved
861872
}
862873

internal/config/env.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ type EnvSandbox struct {
4343
}
4444

4545
type EnvLog struct {
46-
Level string // FASTCLAW_LOG_LEVEL — "debug" / "info" / "warn" / "error"
46+
Level string // FASTCLAW_LOG_LEVEL — "debug" / "info" / "warn" / "error"
47+
Debug bool // FASTCLAW_DEBUG_MODE — enable verbose debug output (prompt dump, etc.)
4748
}
4849

4950
// LoadEnv reads the bootstrap configuration from FASTCLAW_* environment
@@ -106,6 +107,9 @@ func LoadEnv() *EnvConfig {
106107
if v := os.Getenv("FASTCLAW_LOG_LEVEL"); v != "" {
107108
cfg.Log.Level = v
108109
}
110+
if v := os.Getenv("FASTCLAW_DEBUG_MODE"); v == "true" || v == "1" {
111+
cfg.Log.Debug = true
112+
}
109113
return cfg
110114
}
111115

@@ -147,6 +151,15 @@ func applyObjectStoreEnv(cfg *Config) {
147151
}
148152
}
149153

154+
// debugMode is read once at package init. All debug-gated output in
155+
// the codebase checks this via DebugMode().
156+
var debugMode = os.Getenv("FASTCLAW_DEBUG_MODE") == "true" || os.Getenv("FASTCLAW_DEBUG_MODE") == "1"
157+
158+
// DebugMode returns true when FASTCLAW_DEBUG_MODE=true|1. Use this to
159+
// gate verbose output (prompt dumps, request traces, etc.) that is
160+
// useful during development but noisy in production.
161+
func DebugMode() bool { return debugMode }
162+
150163
// ScrubBootSecrets removes credential-bearing env vars from the
151164
// process environment AFTER bootstrap config has been read. Call once
152165
// from the daemon entry point after gateway construction.

0 commit comments

Comments
 (0)