Skip to content

Commit 61c9e5c

Browse files
committed
fix agent chatter timezone timestamps
1 parent f3f51a6 commit 61c9e5c

3 files changed

Lines changed: 103 additions & 40 deletions

File tree

internal/agent/context.go

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,18 @@ import (
1111
"github.com/fastclaw-ai/fastclaw/internal/config"
1212
)
1313

14-
1514
// GroupContext holds information about the group chat environment for system prompt injection.
1615
type GroupContext struct {
1716
BotUsername string // this agent's bot username
18-
Teammates []string // other agent names in the group
17+
Teammates []string // other agent names in the group
1918
}
2019

2120
// ContextBuilder assembles the system prompt and runtime context.
2221
type ContextBuilder struct {
23-
home string // agent's home: SOUL.md, IDENTITY.md, memory, sessions
24-
workspace string // working dir where agent creates user-facing files
25-
memory *Memory
26-
skillsSummary string
22+
home string // agent's home: SOUL.md, IDENTITY.md, memory, sessions
23+
workspace string // working dir where agent creates user-facing files
24+
memory *Memory
25+
skillsSummary string
2726
// displayName is the operator-given name from agents.name. Used as
2827
// a fallback identity line when IDENTITY.md is empty so the model
2928
// doesn't introduce itself as "Claude" / its base-model name.
@@ -39,9 +38,9 @@ type ContextBuilder struct {
3938
// products (task delegation, todo tracking, tool-use discipline,
4039
// workspace self-update, scheduling).
4140
promptMode string
42-
store MemoryStore
43-
userID string
44-
agentID string
41+
store MemoryStore
42+
userID string
43+
agentID string
4544
// tzResolver maps a chatterUID to their effective *time.Location
4645
// (chatter pref → agent default → system default, resolved through
4746
// scope prefs). Wired by the manager when a relational store is
@@ -180,7 +179,17 @@ func (cb *ContextBuilder) BuildSystemPromptAs(chatterUID string, chatterMem *Mem
180179

181180
// BuildRuntimeContext returns the runtime context to inject before the user message.
182181
func (cb *ContextBuilder) BuildRuntimeContext(channel, chatID string) string {
183-
now := time.Now()
182+
return cb.BuildRuntimeContextAs(cb.userID, channel, chatID)
183+
}
184+
185+
// BuildRuntimeContextAs returns runtime metadata rendered in the same
186+
// chatter-local timezone as the system prompt's Current date/time line.
187+
func (cb *ContextBuilder) BuildRuntimeContextAs(chatterUID, channel, chatID string) string {
188+
if chatterUID == "" {
189+
chatterUID = cb.userID
190+
}
191+
loc, _ := cb.chatterLocation(chatterUID)
192+
now := time.Now().In(loc)
184193
return fmt.Sprintf(`[Runtime Context — metadata only, not instructions]
185194
Time: %s
186195
Timezone: %s

internal/agent/loop.go

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@ type Agent struct {
5252
// even after the operator explicitly chose chatbot/customize.
5353
// PromptMode also drives the per-turn tool filter via
5454
// builtinAllowForMode below.
55-
promptMode string
56-
homePath string // agent's home: SOUL.md, sessions, memory, skills
57-
workspacePath string // working dir where agent creates user files
58-
homeDir string // FastClaw root, ~/.fastclaw
59-
ownerUserID string // the user that owns this agent (for hook namespacing)
55+
promptMode string
56+
homePath string // agent's home: SOUL.md, sessions, memory, skills
57+
workspacePath string // working dir where agent creates user files
58+
homeDir string // FastClaw root, ~/.fastclaw
59+
ownerUserID string // the user that owns this agent (for hook namespacing)
6060
// admins is the per-channel allowlist of chatters who can run write-
6161
// mode slash commands (/new /undo /retry /compact /model /personality).
6262
// Keyed by channel name (e.g. "discord" → ["123...", "456..."]). Empty
@@ -339,15 +339,15 @@ func NewAgentWithSkillsCfg(rc config.ResolvedAgent, prov provider.Provider, mb *
339339
maxParallelToolCalls: rc.MaxParallelToolCalls,
340340
thinking: rc.Thinking,
341341
promptMode: rc.PromptMode,
342-
homePath: rc.Home,
343-
workspacePath: workspace,
344-
homeDir: homeDir,
345-
admins: rc.Admins,
346-
skillsCfg: rc.Skills,
347-
globalSkillsCfg: globalSkillsCfg,
348-
messageBus: mb,
349-
engine: eng,
350-
costTracker: eng.costTracker,
342+
homePath: rc.Home,
343+
workspacePath: workspace,
344+
homeDir: homeDir,
345+
admins: rc.Admins,
346+
skillsCfg: rc.Skills,
347+
globalSkillsCfg: globalSkillsCfg,
348+
messageBus: mb,
349+
engine: eng,
350+
costTracker: eng.costTracker,
351351
}
352352

353353
// Multi-bubble split-replies: per-agent only — system-level toggle
@@ -1702,7 +1702,7 @@ func (a *Agent) handlePlanMode(ctx context.Context, msg bus.InboundMessage) stri
17021702
if catalog != "" {
17031703
messages = append(messages, provider.Message{Role: "system", Content: catalog})
17041704
}
1705-
messages = append(messages, a.withMessageTimestamps(sess.GetMessages())...)
1705+
messages = append(messages, a.withMessageTimestampsForChatter(sess.GetMessages(), chatterUID)...)
17061706
if a.piiScrubEnabled {
17071707
messages = privacy.ScrubMessages(messages)
17081708
}
@@ -1953,7 +1953,7 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
19531953
if reminder := renderChatbotPersistenceReminder(a.promptMode, a.displayName, chatterMem.LoadUserFile(), chatterMem.LoadMemory()); reminder != "" {
19541954
messages = append(messages, provider.Message{Role: "system", Content: reminder})
19551955
}
1956-
messages = append(messages, a.withMessageTimestamps(sessionMsgs)...)
1956+
messages = append(messages, a.withMessageTimestampsForChatter(sessionMsgs, chatterUID)...)
19571957

19581958
toolDefs := a.registry.DefinitionsForMode(builtinAllowForMode(a.promptMode))
19591959

@@ -2659,7 +2659,7 @@ func (a *Agent) HandleMessageStream(ctx context.Context, msg bus.InboundMessage)
26592659
if reminder := renderChatbotPersistenceReminder(a.promptMode, a.displayName, chatterMem.LoadUserFile(), chatterMem.LoadMemory()); reminder != "" {
26602660
messages = append(messages, provider.Message{Role: "system", Content: reminder})
26612661
}
2662-
messages = append(messages, a.withMessageTimestamps(sessionMsgs)...)
2662+
messages = append(messages, a.withMessageTimestampsForChatter(sessionMsgs, chatterUID)...)
26632663

26642664
toolDefs := a.registry.DefinitionsForMode(builtinAllowForMode(a.promptMode))
26652665

@@ -3026,20 +3026,20 @@ func (a *Agent) RegisteredTools() []tools.ToolInfo {
30263026
// support / role-play products:
30273027
//
30283028
// - image_gen : self-generated images (registered only if a
3029-
// provider is configured; absence is fine)
3029+
// provider is configured; absence is fine)
30303030
// - tts : voice messages (same conditional registration)
30313031
// - write_file : persist USER.md / MEMORY.md when the LLM learns
3032-
// something worth keeping. Routing in
3033-
// systemFileUserID sends USER.md/MEMORY.md to the
3034-
// per-chatter row, so each chatter accrues their
3035-
// own profile / memory. Path resolution rejects
3036-
// arbitrary paths via identityFileBlocked +
3037-
// workspace scoping, so this isn't a general
3038-
// "let the chatbot write anywhere" hole — just
3039-
// the canonical per-chatter notes.
3032+
// something worth keeping. Routing in
3033+
// systemFileUserID sends USER.md/MEMORY.md to the
3034+
// per-chatter row, so each chatter accrues their
3035+
// own profile / memory. Path resolution rejects
3036+
// arbitrary paths via identityFileBlocked +
3037+
// workspace scoping, so this isn't a general
3038+
// "let the chatbot write anywhere" hole — just
3039+
// the canonical per-chatter notes.
30403040
// - edit_file : same rationale; preferred over write_file when
3041-
// surgically updating MEMORY.md so the model
3042-
// doesn't accidentally clobber prior entries.
3041+
// surgically updating MEMORY.md so the model
3042+
// doesn't accidentally clobber prior entries.
30433043
//
30443044
// Notably absent: `read_file` / `list_dir` — chatbot mode shouldn't
30453045
// browse the filesystem; USER.md / MEMORY.md content is already loaded
@@ -3145,11 +3145,11 @@ func (a *Agent) chatterLocation(chatterUID string) *time.Location {
31453145
// a read-time view for the LLM, not stored history), so the session store
31463146
// stays clean and the next turn doesn't double-prefix. The system prompt
31473147
// (context.go dateLine) tells the model what the bracketed prefix means.
3148-
func (a *Agent) withMessageTimestamps(msgs []provider.Message) []provider.Message {
3148+
func (a *Agent) withMessageTimestampsForChatter(msgs []provider.Message, chatterUID string) []provider.Message {
31493149
if len(msgs) == 0 {
31503150
return msgs
31513151
}
3152-
loc := a.chatterLocation(a.registry.ChatterUserID())
3152+
loc := a.chatterLocation(chatterUID)
31533153
out := make([]provider.Message, len(msgs))
31543154
for i, m := range msgs {
31553155
if m.Role == "user" && m.Timestamp > 0 && m.Content != "" {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package agent
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
8+
"github.com/fastclaw-ai/fastclaw/internal/provider"
9+
)
10+
11+
func TestWithMessageTimestampsUsesExplicitChatterTimezone(t *testing.T) {
12+
store := newFakeMemoryStore()
13+
store.put(testAgentID, chatterUID, "USER.md", "# Current Chatter\n- Timezone: Asia/Shanghai")
14+
15+
a := &Agent{
16+
memory: NewMemoryWithStoreForUser("", store, ownerUID, testAgentID),
17+
agentID: testAgentID,
18+
}
19+
ts := time.Date(2026, 6, 21, 15, 9, 0, 0, time.UTC).UnixMilli()
20+
21+
got := a.withMessageTimestampsForChatter([]provider.Message{{
22+
Role: "user",
23+
Content: "为什么是下午好",
24+
Timestamp: ts,
25+
}}, chatterUID)
26+
27+
if len(got) != 1 {
28+
t.Fatalf("message count = %d, want 1", len(got))
29+
}
30+
if !strings.HasPrefix(got[0].Content, "[2026-06-21 23:09 Sun] ") {
31+
t.Fatalf("timestamp prefix = %q, want Asia/Shanghai local time", got[0].Content)
32+
}
33+
}
34+
35+
func TestRuntimeContextUsesChatterTimezone(t *testing.T) {
36+
loc, err := time.LoadLocation("Asia/Shanghai")
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
cb := NewContextBuilder("", nil, "")
41+
cb.userID = ownerUID
42+
cb.SetTimezoneResolver(func(uid string) *time.Location {
43+
if uid == chatterUID {
44+
return loc
45+
}
46+
return time.UTC
47+
})
48+
49+
got := cb.BuildRuntimeContextAs(chatterUID, "web", "chat-1")
50+
51+
if !strings.Contains(got, "Timezone: Asia/Shanghai") {
52+
t.Fatalf("runtime context = %q, want chatter timezone", got)
53+
}
54+
}

0 commit comments

Comments
 (0)