Skip to content

Commit d4b7a63

Browse files
idoubiclaude
andcommitted
feat: per-chatter timezone for prompts and cron scheduling
The system prompt's date line and cron schedules were rendered/evaluated in the server's timezone (UTC on hosted pods), so a SOUL.md instruction like "use 东八区" reliably lost to the clock value, and "每天早上 9 点" reminders fired at 下午 5 点 Beijing time. Timezone is a property of the chatter, not the agent — different-timezone users of the same agent each get their own wall clock: - scope: new prefs namespace with chatter-first resolution (chatter,agent) → (chatter) → (agent) → (system); SaveUserTimezone writes at user scope so the preference follows the chatter across agents - set_timezone tool: persists the chatter's IANA timezone where the runtime reads it (also exposed in chatbot mode); a USER.md note alone never affected scheduling - system prompt: date line rendered in the chatter's local timezone via ContextBuilder.tzResolver — the model never does offset arithmetic - cron: jobs freeze the chatter's timezone at creation (was hardcoded "UTC"); zone-less 'once' datetimes parse in it; recurrences evaluate in it via NextOccurrenceIn. cron-type jobs no longer fire once immediately on creation — first run is the actual next occurrence - heartbeat: timestamps in the agent-default timezone - deploy: TZ=Asia/Shanghai server-default fallback in compose/k8s/helm Legacy rows keep their "UTC" semantics; empty timezone = server-local. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f9e509a commit d4b7a63

17 files changed

Lines changed: 524 additions & 16 deletions

File tree

deploy/docker/docker-compose.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ services:
2626
volumes:
2727
- fastclaw-data:/data/.fastclaw
2828
environment:
29+
# Server default timezone (IANA name). This is the LAST fallback in
30+
# the chain chatter pref → agent default → system default → TZ —
31+
# without it the container runs UTC and agents whose chatters never
32+
# set a timezone get UTC wall clocks and UTC-fired schedules.
33+
TZ: "${TZ:-Asia/Shanghai}"
2934
# FASTCLAW_HOME — sqlite db path + sandbox root + skill cache live here
3035
FASTCLAW_HOME: /data/.fastclaw
3136
# Bind to all interfaces so the host port mapping works

deploy/helm/fastclaw/templates/configmap.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ data:
99
FASTCLAW_BIND: "all"
1010
FASTCLAW_STORAGE_TYPE: "postgres"
1111
FASTCLAW_STORAGE_AUTO_MIGRATE: "true"
12+
{{- with .Values.timezone }}
13+
## Server default timezone — last fallback after chatter / agent /
14+
## system prefs.
15+
TZ: {{ . | quote }}
16+
{{- end }}
1217

1318
## Object store
1419
FASTCLAW_OBJECT_STORE_TYPE: {{ .Values.objectStore.type | quote }}

deploy/helm/fastclaw/values.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ image:
1212
# imagePullSecrets:
1313
# - name: regcred
1414

15+
## Server default timezone (IANA name) — the LAST fallback in the chain
16+
## chatter pref → agent default → system default → TZ. Empty = container
17+
## default (UTC): agents whose chatters never set a timezone then see UTC
18+
## wall clocks and get UTC-fired schedules.
19+
timezone: "Asia/Shanghai"
20+
1521
## Gateway
1622
gateway:
1723
replicas: 2

deploy/k8s/fastclaw.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ data:
7878
FASTCLAW_STORAGE_TYPE: "postgres"
7979
FASTCLAW_STORAGE_AUTO_MIGRATE: "true"
8080

81+
# Server default timezone (IANA name) — the LAST fallback in the chain
82+
# chatter pref → agent default → system default → TZ. Without it pods
83+
# run UTC, so agents whose chatters never set a timezone see UTC wall
84+
# clocks and get UTC-fired schedules.
85+
TZ: "Asia/Shanghai"
86+
8187
# ─── Provider 1: Aliyun OSS (default example) ───
8288
FASTCLAW_OBJECT_STORE_TYPE: "aliyun-oss"
8389
FASTCLAW_OBJECT_STORE_REGION: "cn-hangzhou"

deploy/multi-pod/docker-compose.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ services:
7979
dockerfile: Dockerfile
8080
environment:
8181
# Same config on both pods — that's the whole point of "stateless".
82+
# TZ included so cross-pod runs exercise the server-default
83+
# timezone fallback the same way single-pod deploys do.
84+
TZ: Asia/Shanghai
8285
FASTCLAW_STORAGE_TYPE: postgres
8386
FASTCLAW_STORAGE_DSN: postgres://fastclaw:fastclaw@db:5432/fastclaw?sslmode=disable
8487
FASTCLAW_MODE: cloud
@@ -104,6 +107,7 @@ services:
104107
context: ../..
105108
dockerfile: Dockerfile
106109
environment:
110+
TZ: Asia/Shanghai
107111
FASTCLAW_STORAGE_TYPE: postgres
108112
FASTCLAW_STORAGE_DSN: postgres://fastclaw:fastclaw@db:5432/fastclaw?sslmode=disable
109113
FASTCLAW_MODE: cloud

internal/agent/context.go

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,12 @@ type ContextBuilder struct {
189189
store MemoryStore
190190
userID string
191191
agentID string
192+
// tzResolver maps a chatterUID to their effective *time.Location
193+
// (chatter pref → agent default → system default, resolved through
194+
// scope prefs). Wired by the manager when a relational store is
195+
// available; nil (or a nil return) falls back to server-local time,
196+
// which preserves the legacy single-tenant behavior.
197+
tzResolver func(chatterUID string) *time.Location
192198
}
193199

194200
// ctx returns a context tagged with this builder's user, used when reading
@@ -233,6 +239,26 @@ func (cb *ContextBuilder) SetDisplayName(n string) { cb.displayName = n }
233239

234240
// resolvedPromptMode returns the active mode with empty/unknown values
235241
// normalized to PromptModeAgent so callers can switch on the result.
242+
// SetTimezoneResolver wires the chatterUID → *time.Location lookup used
243+
// to render the prompt's date line (and runtime context) in the
244+
// chatter's local time. Re-apply after rebuilding the ContextBuilder
245+
// (ReloadWorkspaceFiles), like the other Set* state.
246+
func (cb *ContextBuilder) SetTimezoneResolver(f func(chatterUID string) *time.Location) {
247+
cb.tzResolver = f
248+
}
249+
250+
// chatterLocation resolves the timezone to render times in for a given
251+
// chatter. Falls back to server-local when no resolver is wired or it
252+
// has nothing for this chatter.
253+
func (cb *ContextBuilder) chatterLocation(chatterUID string) *time.Location {
254+
if cb.tzResolver != nil {
255+
if loc := cb.tzResolver(chatterUID); loc != nil {
256+
return loc
257+
}
258+
}
259+
return time.Local
260+
}
261+
236262
func (cb *ContextBuilder) resolvedPromptMode() string {
237263
switch cb.promptMode {
238264
case config.PromptModeChatbot, config.PromptModeCustomize:
@@ -285,9 +311,17 @@ func (cb *ContextBuilder) BuildSystemPromptAs(chatterUID string, chatterMem *Mem
285311
// which then often runs in parallel with a web_search whose
286312
// query was built from the model's stale year. Putting now() in
287313
// the prompt removes the dependency at the root.
288-
now := time.Now()
314+
//
315+
// Rendered in the CHATTER's timezone (tzResolver), not the
316+
// server's: a hosted pod runs UTC while the person typing is in
317+
// 东八区, and a SOUL.md instruction to "use UTC+8" reliably loses
318+
// to a clock value labeled "Use this". Pre-converting here means
319+
// the model never does timezone arithmetic — different chatters of
320+
// the same agent each see their own wall clock.
321+
loc := cb.chatterLocation(chatterUID)
322+
now := time.Now().In(loc)
289323
wd := now.Weekday().String()
290-
dateLine := fmt.Sprintf("Current date/time: %s (%s, %s). Use this — do NOT call `date` to learn what day it is.",
324+
dateLine := fmt.Sprintf("Current date/time: %s (%s, %s — the chatter's local timezone). Use this — do NOT call `date` to learn what day it is.",
291325
now.Format("2006-01-02 15:04:05 -0700"), wd, now.Location().String())
292326

293327
switch mode {
@@ -357,7 +391,9 @@ MEMORY.md — pick the right file):
357391
- Chatter tells you their **name** / nickname / what to call them → ` + bt + `USER.md` + bt + `
358392
- Chatter tells you their **role / job / background** → ` + bt + `USER.md` + bt + `
359393
- Chatter tells you their **preferences** (language, tone, style) → ` + bt + `USER.md` + bt + `
360-
- Chatter tells you their **location / timezone** → ` + bt + `USER.md` + bt + `
394+
- Chatter tells you their **location / timezone** → call ` + bt + `set_timezone` + bt + `
395+
(if available — it switches your clock and their scheduled tasks to
396+
their local time; a USER.md note alone does NOT), then note it in ` + bt + `USER.md` + bt + ` too
361397
- A decision you made together that matters next time → ` + bt + `MEMORY.md` + bt + `
362398
- A recurring topic / ongoing project / shared context → ` + bt + `MEMORY.md` + bt + `
363399
- Chatter explicitly says "remember that X" (not about who they are) → ` + bt + `MEMORY.md` + bt + `
@@ -840,7 +876,9 @@ You have the ability to update workspace files to maintain knowledge over time:
840876
Use the write_file tool to update these files when appropriate. Keep entries concise and useful.
841877
842878
# Scheduling Time-Bound Tasks
843-
When the user asks you to do something at a specific moment, after a delay, or on a recurring schedule (e.g. "5 分钟后提醒我", "每天 9 点", "every Monday morning"), call the create_cron_job tool. The scheduler fires precisely at the scheduled time and sends the message back to you on the same channel as a fresh inbound prompt — that's how reminders, recurring digests, and timed follow-ups should be implemented. NEVER write timed reminders into HEARTBEAT.md: that file is reviewed only on a coarse heartbeat tick and is wrong for any short-fuse or precise-timing request.`)
879+
When the user asks you to do something at a specific moment, after a delay, or on a recurring schedule (e.g. "5 分钟后提醒我", "每天 9 点", "every Monday morning"), call the create_cron_job tool. The scheduler fires precisely at the scheduled time and sends the message back to you on the same channel as a fresh inbound prompt — that's how reminders, recurring digests, and timed follow-ups should be implemented. NEVER write timed reminders into HEARTBEAT.md: that file is reviewed only on a coarse heartbeat tick and is wrong for any short-fuse or precise-timing request.
880+
881+
Schedules are interpreted in the CHATTER'S local timezone — the same one your "Current date/time" line above is rendered in. Write "每天 9 点" as '0 9 * * *' directly; do NOT convert to UTC. If the chatter mentions being in a different timezone or city, call set_timezone first so both your clock and their schedules follow it.`)
844882
}
845883

846884
return strings.Join(parts, "\n\n---\n\n")

internal/agent/heartbeat.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,14 @@ func (hb *Heartbeat) tick(ctx context.Context) {
6868
// 1. Check HEARTBEAT.md for tasks
6969
tasks := hb.loadHeartbeatTasks()
7070
if tasks != "" {
71-
now := time.Now()
71+
// Agent-default timezone (chatterUID="" → agent/system prefs,
72+
// else server local): heartbeat has no chatter, but HEARTBEAT.md
73+
// conditions are written in the operator's wall clock, not the
74+
// pod's (UTC on hosted deployments).
75+
now := time.Now().In(hb.agent.chatterLocation(""))
7276
heartbeatMsg := fmt.Sprintf(
7377
"[Heartbeat — %s]\nCurrent tasks from HEARTBEAT.md:\n%s\n\nReview these tasks and take action on any that need attention based on the current date/time.",
74-
now.Format("2006-01-02 15:04:05"),
78+
now.Format("2006-01-02 15:04:05 -0700"),
7579
tasks,
7680
)
7781

internal/agent/loop.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/fastclaw-ai/fastclaw/internal/privacy"
2222
"github.com/fastclaw-ai/fastclaw/internal/provider"
2323
"github.com/fastclaw-ai/fastclaw/internal/sandbox"
24+
"github.com/fastclaw-ai/fastclaw/internal/scope"
2425
"github.com/fastclaw-ai/fastclaw/internal/session"
2526
"github.com/fastclaw-ai/fastclaw/internal/store"
2627
"github.com/fastclaw-ai/fastclaw/internal/toolproviders"
@@ -2989,6 +2990,9 @@ var chatbotBuiltinAllowlist = []string{
29892990
"tts",
29902991
"write_file",
29912992
"edit_file",
2993+
// set_timezone keeps "their local time" right for chat (greetings,
2994+
// "晚安" timing) — chatbots need it as much as full agents do.
2995+
"set_timezone",
29922996
}
29932997

29942998
// builtinAllowForMode returns the built-in tool name allowlist for the
@@ -3011,6 +3015,21 @@ func (a *Agent) WorkspacePath() string {
30113015
return a.workspacePath
30123016
}
30133017

3018+
// chatterLocation resolves the effective timezone for a chatter via
3019+
// scope prefs (chatter pref → agent default → system default). Server-
3020+
// local when no relational store is wired or nothing is configured —
3021+
// the legacy single-tenant behavior. Passed to the ContextBuilder as
3022+
// the tzResolver so the system prompt's date line renders in the
3023+
// chatter's wall clock; the cron tool runs the same resolution at
3024+
// job-creation time.
3025+
func (a *Agent) chatterLocation(chatterUID string) *time.Location {
3026+
if a.dataStore == nil {
3027+
return time.Local
3028+
}
3029+
tz := scope.Timezone(context.Background(), a.dataStore, chatterUID, a.agentID)
3030+
return scope.LoadLocationOrLocal(tz)
3031+
}
3032+
30143033
// UpdateConfig updates the agent's runtime config (model, temperature, etc.)
30153034
func (a *Agent) UpdateConfig(rc config.ResolvedAgent) {
30163035
a.model = rc.Model
@@ -3135,6 +3154,12 @@ func (a *Agent) ReloadWorkspaceFiles() {
31353154
a.ctxBuilder.agentID = a.name
31363155
a.ctxBuilder.userID = a.ownerUserID
31373156
}
3157+
// Chatter-timezone date line — same re-apply rule as the Store
3158+
// wiring above: the rebuilt ContextBuilder starts with a nil
3159+
// resolver and would silently fall back to server-local time.
3160+
if a.dataStore != nil {
3161+
a.ctxBuilder.SetTimezoneResolver(a.chatterLocation)
3162+
}
31383163
}
31393164

31403165
// extractMediaPaths scans tool output for MEDIA: lines and returns file paths.

internal/agent/manager.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,11 @@ func (m *Manager) buildAgent(rc config.ResolvedAgent, prov provider.Provider, mb
222222
// at execute time (bindSession stamps them per-turn) so the
223223
// fired message routes back to the originating chat.
224224
tools.RegisterCronTools(ag.registry, m.opts.dataStore, m.uid, rc.ID)
225+
// set_timezone persists the chatter's IANA timezone into scope
226+
// prefs — the same rows the system-prompt date line and cron
227+
// scheduling resolve through. Needs the relational store, so it
228+
// rides the same guard as cron.
229+
tools.RegisterTimezoneTool(ag.registry, m.opts.dataStore)
225230
// /goal feature: token-accounting hook + update_goal tool, all
226231
// keyed on the agent's owner (set above by SetOwnerUserID).
227232
// Same dataStore guard as cron because both features need the
@@ -232,6 +237,10 @@ func (m *Manager) buildAgent(rc config.ResolvedAgent, prov provider.Provider, mb
232237
// on an in-memory counter that restart-clears) can hit the
233238
// store directly without re-plumbing through Manager.
234239
ag.dataStore = m.opts.dataStore
240+
// Date line in the chatter's timezone — needs dataStore for the
241+
// scope-prefs lookup, hence wired here and re-applied by
242+
// ReloadWorkspaceFiles after every ctxBuilder rebuild.
243+
ag.ctxBuilder.SetTimezoneResolver(ag.chatterLocation)
235244
}
236245
// Stamp agentID even when no workspaceStore is wired (single-user
237246
// local mode), so usage metering can record per-agent rollups.

internal/agent/tools/cron.go

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"time"
1010

1111
"github.com/fastclaw-ai/fastclaw/internal/cron"
12+
"github.com/fastclaw-ai/fastclaw/internal/scope"
1213
"github.com/fastclaw-ai/fastclaw/internal/store"
1314
)
1415

@@ -42,7 +43,7 @@ func RegisterCronTools(r *Registry, st store.Store, userID, agentID string) {
4243
},
4344
"schedule": map[string]interface{}{
4445
"type": "string",
45-
"description": "When to fire. For type='cron': a 5-field cron expression like '0 9 * * *'. For type='interval': a duration like '5m' / '30m' / '2h'. For type='once': an ISO-8601 datetime in UTC like '2026-05-02T15:56:52'.",
46+
"description": "When to fire, in the CHATTER'S local timezone (the timezone shown on the 'Current date/time' line of your system prompt) — write '每天早上 9 点' as '0 9 * * *' directly, do NOT convert to UTC. For type='cron': a 5-field cron expression like '0 9 * * *'. For type='interval': a duration like '5m' / '30m' / '2h'. For type='once': an ISO-8601 datetime like '2026-05-02T15:56:52' (no offset = chatter's local time; an explicit offset like '+08:00' or 'Z' is honored as written).",
4647
},
4748
"message": map[string]interface{}{
4849
"type": "string",
@@ -104,6 +105,15 @@ func makeCreateCronJob(st store.Store, r *Registry, userID, agentID string) Tool
104105
channel := r.MessageChannel()
105106
chatID := r.MessageChatID()
106107

108+
// The chatter's effective timezone governs how the schedule is
109+
// read: zone-less 'once' datetimes and cron wall-clock fields
110+
// both mean "their local time" (the same zone the system
111+
// prompt's date line is rendered in), not the server's. The
112+
// resolved name is frozen onto the row so the scheduler keeps
113+
// evaluating recurrences in it even if the chatter later moves.
114+
tzName := scope.Timezone(ctx, st, r.ChatterUserID(), agentID)
115+
loc := scope.LoadLocationOrLocal(tzName)
116+
107117
id := generateUUID()
108118
now := time.Now()
109119

@@ -113,9 +123,10 @@ func makeCreateCronJob(st store.Store, r *Registry, userID, agentID string) Tool
113123
case "once":
114124
t, err := time.Parse(time.RFC3339, args.Schedule)
115125
if err != nil {
116-
t, err = time.Parse("2006-01-02T15:04:05", args.Schedule)
126+
// No explicit offset — interpret in the chatter's zone.
127+
t, err = time.ParseInLocation("2006-01-02T15:04:05", args.Schedule, loc)
117128
if err != nil {
118-
return "", fmt.Errorf("once schedule must be ISO datetime (e.g. 2026-05-06T15:30:00Z), got: %q", args.Schedule)
129+
return "", fmt.Errorf("once schedule must be ISO datetime (e.g. 2026-05-06T15:30:00), got: %q", args.Schedule)
119130
}
120131
}
121132
if t.Before(now) {
@@ -130,8 +141,10 @@ func makeCreateCronJob(st store.Store, r *Registry, userID, agentID string) Tool
130141
}
131142
nextRun = now.Add(dur)
132143
default:
133-
// cron expression — fire on next scheduler tick
134-
nextRun = now
144+
// cron expression — first occurrence in the chatter's zone.
145+
// (Previously nextRun=now, which fired the job once
146+
// immediately on creation — a spurious reminder.)
147+
nextRun = cron.NextOccurrenceIn(args.Schedule, now, loc)
135148
}
136149

137150
job := &store.CronJobRecord{
@@ -143,7 +156,10 @@ func makeCreateCronJob(st store.Store, r *Registry, userID, agentID string) Tool
143156
Message: args.Message,
144157
Channel: channel,
145158
ChatID: chatID,
146-
Timezone: "UTC",
159+
// "" = server-local; the scheduler's LocationOf maps it
160+
// the same way LoadLocationOrLocal did above, so creation
161+
// and recurrence agree.
162+
Timezone: tzName,
147163
Enabled: true,
148164
NextRun: &nextRun,
149165
CreatedAt: now,
@@ -156,7 +172,15 @@ func makeCreateCronJob(st store.Store, r *Registry, userID, agentID string) Tool
156172
// Wake the scheduler to pick up this new job
157173
cron.NotifyJobCreated()
158174

159-
return fmt.Sprintf("Cron job created successfully.\nID: %s\nName: %s\nSchedule: %s\nType: %s", id, args.Name, args.Schedule, jobType), nil
175+
// Echo the effective timezone + first fire so the model can
176+
// confirm the local-time interpretation to the user ("好的,
177+
// 北京时间每天 9 点") instead of guessing.
178+
tzShown := tzName
179+
if tzShown == "" {
180+
tzShown = loc.String() + " (server default)"
181+
}
182+
return fmt.Sprintf("Cron job created successfully.\nID: %s\nName: %s\nSchedule: %s\nType: %s\nTimezone: %s\nFirst fire: %s",
183+
id, args.Name, args.Schedule, jobType, tzShown, nextRun.In(loc).Format("2006-01-02 15:04:05 -0700")), nil
160184
}
161185
}
162186

0 commit comments

Comments
 (0)