Skip to content

Commit 48ee0b3

Browse files
committed
fix runtime settings and empty chat responses
1 parent 61c9e5c commit 48ee0b3

9 files changed

Lines changed: 252 additions & 104 deletions

File tree

internal/agent/loop.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2045,6 +2045,12 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
20452045
a.maybeRecoverToolCalls(resp)
20462046

20472047
if !resp.HasToolCalls() {
2048+
if strings.TrimSpace(resp.Content) == "" {
2049+
emptyMsg := "model returned an empty response"
2050+
emitEvent(ctx, ChatEvent{Type: "error", Data: map[string]any{"message": emptyMsg}})
2051+
emitEvent(ctx, ChatEvent{Type: "done"})
2052+
return emptyMsg
2053+
}
20482054
asst := provider.Message{Role: "assistant", Content: resp.Content, Thinking: resp.Thinking, Timestamp: time.Now().UnixMilli(), RawAssistant: resp.RawAssistant}
20492055
sess.Append(asst)
20502056
emitEvent(ctx, ChatEvent{Type: "content", Data: map[string]any{"content": resp.Content}})

internal/config/config.go

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ type TaskQueueCfg struct {
168168
TaskTimeoutSec int `json:"taskTimeoutSec,omitempty"`
169169
}
170170

171+
// PrefsCfg holds runtime preferences that can be set at system, user, or
172+
// agent scope. Timezone is an IANA name such as "Asia/Shanghai".
173+
type PrefsCfg struct {
174+
Timezone string `json:"timezone,omitempty"`
175+
}
176+
171177
// SandboxCfg holds sandbox configuration for an agent.
172178
//
173179
// Image is the legacy single-slot image/template/snapshot — read-only
@@ -295,6 +301,7 @@ type Config struct {
295301
Memory MemoryCfg `json:"memory,omitempty"`
296302
Privacy PrivacyCfg `json:"privacy,omitempty"`
297303
SkillsLearner SkillsLearnerCfg `json:"skillsLearner,omitempty"`
304+
Prefs PrefsCfg `json:"prefs,omitempty"`
298305
}
299306

300307
// ModelCost holds pricing info for a model.
@@ -359,9 +366,9 @@ type AgentDefaults struct {
359366
// naturally serializes. 0 = unlimited (no cap, current behavior).
360367
// Useful when downstream APIs (Brave free tier 1RPS, etc.) can't
361368
// take a parallel burst.
362-
MaxParallelToolCalls int `json:"maxParallelToolCalls,omitempty"`
363-
Thinking string `json:"thinking,omitempty"`
364-
PolicyPreset string `json:"policy,omitempty"`
369+
MaxParallelToolCalls int `json:"maxParallelToolCalls,omitempty"`
370+
Thinking string `json:"thinking,omitempty"`
371+
PolicyPreset string `json:"policy,omitempty"`
365372
// PromptMode lives here so the agent-scope `agents.defaults`
366373
// config row (written by CLI and dashboard) round-trips into
367374
// ResolvedAgent at userspace assembly time — see
@@ -389,8 +396,8 @@ type AgentDefaults struct {
389396
// configs table at scope=agent and are merged in via scope.SettingInto
390397
// during userspace load.
391398
type AgentEntry struct {
392-
ID string `json:"id"`
393-
UserID string `json:"userId,omitempty"`
399+
ID string `json:"id"`
400+
UserID string `json:"userId,omitempty"`
394401
// Name mirrors agents.name (the operator-given display name) and is
395402
// carried through to ResolvedAgent.DisplayName so the system prompt
396403
// can stamp a fallback identity line when IDENTITY.md is empty.
@@ -400,12 +407,12 @@ type AgentEntry struct {
400407
Temperature float64 `json:"temperature,omitempty"`
401408
MaxToolIterations int `json:"maxToolIterations,omitempty"`
402409
MaxParallelToolCalls int `json:"maxParallelToolCalls,omitempty"`
403-
Skills []string `json:"skills,omitempty"`
404-
MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"`
405-
AlwaysLoadSkills []string `json:"alwaysLoadSkills,omitempty"`
406-
Thinking string `json:"thinking,omitempty"`
407-
Sandbox SandboxCfg `json:"sandbox,omitempty"`
408-
PolicyPreset string `json:"policy,omitempty"`
410+
Skills []string `json:"skills,omitempty"`
411+
MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"`
412+
AlwaysLoadSkills []string `json:"alwaysLoadSkills,omitempty"`
413+
Thinking string `json:"thinking,omitempty"`
414+
Sandbox SandboxCfg `json:"sandbox,omitempty"`
415+
PolicyPreset string `json:"policy,omitempty"`
409416
// PromptMode selects how heavily the framework system prompt
410417
// participates AND which built-in tools the LLM sees. Empty =
411418
// "agent" (current default) for backward compatibility. See
@@ -537,12 +544,12 @@ type AgentFileConfig struct {
537544
Temperature float64 `json:"temperature,omitempty"`
538545
MaxToolIterations int `json:"maxToolIterations,omitempty"`
539546
MaxParallelToolCalls int `json:"maxParallelToolCalls,omitempty"`
540-
Workspace string `json:"workspace,omitempty"`
541-
Skills SkillsConfig `json:"skills,omitempty"`
542-
MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"`
543-
ToolProviders map[string]ToolProviderCfg `json:"toolProviders,omitempty"`
544-
Tools map[string]ToolCategoryCfg `json:"tools,omitempty"`
545-
Providers map[string]ProviderConfig `json:"providers,omitempty"`
547+
Workspace string `json:"workspace,omitempty"`
548+
Skills SkillsConfig `json:"skills,omitempty"`
549+
MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"`
550+
ToolProviders map[string]ToolProviderCfg `json:"toolProviders,omitempty"`
551+
Tools map[string]ToolCategoryCfg `json:"tools,omitempty"`
552+
Providers map[string]ProviderConfig `json:"providers,omitempty"`
546553
// PromptMode mirrors AgentEntry.PromptMode at the file-config layer.
547554
// Non-empty values override the entry-level setting.
548555
PromptMode string `json:"promptMode,omitempty"`
@@ -608,13 +615,13 @@ type ResolvedAgent struct {
608615
MaxToolIterations int
609616
MaxParallelToolCalls int
610617
Thinking string
611-
Skills SkillsConfig
612-
MCPServers map[string]MCPServerConfig
613-
Sandbox SandboxCfg
614-
PolicyPreset string
615-
ToolProviders map[string]ToolProviderCfg
616-
Tools map[string]ToolCategoryCfg
617-
Providers map[string]ProviderConfig
618+
Skills SkillsConfig
619+
MCPServers map[string]MCPServerConfig
620+
Sandbox SandboxCfg
621+
PolicyPreset string
622+
ToolProviders map[string]ToolProviderCfg
623+
Tools map[string]ToolCategoryCfg
624+
Providers map[string]ProviderConfig
618625
// Admins is the per-channel admin allowlist for write-mode slash
619626
// commands. See AgentFileConfig.Admins for semantics + default.
620627
Admins map[string][]string

internal/provider/anthropic.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,15 @@ func toAnthropicMessages(msgs []Message) (string, []anthropicMessage) {
240240
am.Content, _ = json.Marshal("")
241241
}
242242

243+
if len(am.Content) == 0 {
244+
// Some Anthropic-compatible providers (notably z.ai) reject
245+
// `content: null` even though a degenerate empty assistant
246+
// message can appear in historical sessions after an aborted
247+
// or empty streamed turn. The schema accepts a string, so
248+
// serialize the empty content explicitly instead of letting
249+
// json.RawMessage's nil value become null.
250+
am.Content, _ = json.Marshal("")
251+
}
243252
out = append(out, am)
244253
}
245254

internal/provider/anthropic_orphan_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,31 @@ func TestToAnthropicMessagesOrphanAssistantRawOnly(t *testing.T) {
153153
}
154154
}
155155

156+
func TestToAnthropicMessagesEmptyAssistantDoesNotEmitNullContent(t *testing.T) {
157+
msgs := []Message{
158+
{Role: "user", Content: "hello"},
159+
{Role: "assistant"},
160+
{Role: "user", Content: "are you there?"},
161+
}
162+
163+
_, out := toAnthropicMessages(msgs)
164+
165+
if len(out) != 3 {
166+
t.Fatalf("expected 3 messages, got %d: %+v", len(out), out)
167+
}
168+
if out[1].Role != "assistant" {
169+
t.Fatalf("message[1] role = %q, want assistant", out[1].Role)
170+
}
171+
if string(out[1].Content) != `""` {
172+
t.Fatalf("message[1] content = %s, want JSON empty string", string(out[1].Content))
173+
}
174+
for i, am := range out {
175+
if string(am.Content) == "null" || len(am.Content) == 0 {
176+
t.Fatalf("message[%d] has null/empty content: %+v", i, am)
177+
}
178+
}
179+
}
180+
156181
func allText(out []anthropicMessage) string {
157182
var sb strings.Builder
158183
for _, am := range out {

internal/setup/handlers.go

Lines changed: 72 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ func (s *Server) loadUserConfig(r *http.Request) (*config.Config, error) {
5252
return nil, err
5353
}
5454
}
55+
if err := scope.SettingInto(r.Context(), s.dataStore, scope.PrefsNamespace, uid, "", &cfg.Prefs); err != nil {
56+
return nil, err
57+
}
5558
if provs, err := scope.Providers(r.Context(), s.dataStore, uid, ""); err == nil {
5659
for k, v := range provs {
5760
cfg.Providers[k] = v
@@ -197,7 +200,7 @@ var settingNamespaces = []settingNamespace{
197200
dst: func(c *config.Config) interface{} { return &c.Teams },
198201
collect: func(c *config.Config) map[string]interface{} { return wrapKeyed(c.Teams) }},
199202
{namespace: "bindings",
200-
dst: func(c *config.Config) interface{} { return &c.Bindings },
203+
dst: func(c *config.Config) interface{} { return &c.Bindings },
201204
collect: func(c *config.Config) map[string]interface{} {
202205
if len(c.Bindings) == 0 {
203206
return nil
@@ -506,6 +509,7 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
506509
if s.dataStore != nil {
507510
_ = scope.SettingInto(r.Context(), s.dataStore, "agents.defaults", "", "", &sysDefaults)
508511
}
512+
serverTimezone := time.Local.String()
509513
// Marshal-then-extend keeps the response shape compatible (existing
510514
// callers ignore the extra `meta` key) without forcing a refactor of
511515
// config.Config to carry presentation metadata.
@@ -514,6 +518,7 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
514518
_ = json.Unmarshal(blob, &out)
515519
out["meta"] = map[string]any{
516520
"systemDefaultModel": sysDefaults.Model,
521+
"serverTimezone": serverTimezone,
517522
}
518523
jsonResponse(w, http.StatusOK, out)
519524
}
@@ -534,6 +539,22 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
534539
jsonResponse(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
535540
return
536541
}
542+
var raw struct {
543+
Prefs *config.PrefsCfg `json:"prefs"`
544+
Skills *struct {
545+
AgentEntries map[string]map[string]config.SkillEntryCfg `json:"agentEntries"`
546+
} `json:"skills"`
547+
}
548+
_ = json.Unmarshal(buf, &raw)
549+
if raw.Prefs != nil {
550+
raw.Prefs.Timezone = strings.TrimSpace(raw.Prefs.Timezone)
551+
if raw.Prefs.Timezone != "" {
552+
if _, err := time.LoadLocation(raw.Prefs.Timezone); err != nil {
553+
jsonResponse(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "invalid timezone: use an IANA name like Asia/Shanghai"})
554+
return
555+
}
556+
}
557+
}
537558
merged, err := s.loadUserConfig(r)
538559
if err != nil {
539560
jsonResponse(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
@@ -556,12 +577,18 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
556577
// name=skills.entries). Pull from the raw body — not from the
557578
// merged Config — so we only touch agents the caller actually
558579
// patched, and don't echo every existing override back as a write.
559-
var raw struct {
560-
Skills *struct {
561-
AgentEntries map[string]map[string]config.SkillEntryCfg `json:"agentEntries"`
562-
} `json:"skills"`
580+
if raw.Prefs != nil {
581+
sc, scopeID := s.scopeForSave(r)
582+
uid, aid := scope.OwnershipFromScope(sc, scopeID)
583+
data := map[string]interface{}{}
584+
if raw.Prefs.Timezone != "" {
585+
data["timezone"] = raw.Prefs.Timezone
586+
}
587+
if err := scope.SaveSetting(r.Context(), s.dataStore, uid, aid, scope.PrefsNamespace, data); err != nil {
588+
jsonResponse(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
589+
return
590+
}
563591
}
564-
_ = json.Unmarshal(buf, &raw)
565592
if raw.Skills != nil && raw.Skills.AgentEntries != nil {
566593
for agentID, entries := range raw.Skills.AgentEntries {
567594
rec, err := s.dataStore.GetAgent(r.Context(), agentID)
@@ -810,15 +837,15 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
810837
// --- chat handlers (delegate to per-user agent) ---
811838

812839
type chatRequest struct {
813-
AgentID string `json:"agentId,omitempty"`
814-
SessionID string `json:"sessionId"`
840+
AgentID string `json:"agentId,omitempty"`
841+
SessionID string `json:"sessionId"`
815842
// ProjectID, when non-empty AND the session row doesn't yet exist,
816843
// is the "this chat belongs to project X" hint the URL carries
817844
// (`?project=<pid>`) before the first message. Once the row exists
818845
// it's authoritative — the server reads project_id from the row
819846
// and ignores any later hint.
820-
ProjectID string `json:"projectId,omitempty"`
821-
Message string `json:"message"`
847+
ProjectID string `json:"projectId,omitempty"`
848+
Message string `json:"message"`
822849
// Images carries data URLs / HTTPS URLs for image attachments. The
823850
// web client historically sends them under `imageUrls` (camelCase)
824851
// while the API path uses `images`; we accept both and merge below
@@ -1073,6 +1100,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
10731100
defer keepalive.Stop()
10741101

10751102
clientGone := r.Context().Done()
1103+
forwardedAny := false
10761104
// turnPending flips on when the slash handler reports it queued a
10771105
// continuation via bus.Inbound (`turn_pending` event). The POST
10781106
// goroutine's HandleMessage has already returned, but the real
@@ -1109,9 +1137,12 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
11091137
continue
11101138
}
11111139
if env.Event.Type == "done" {
1140+
forwardEvent(w, flusher, env)
1141+
forwardedAny = true
11121142
return
11131143
}
11141144
forwardEvent(w, flusher, env)
1145+
forwardedAny = true
11151146
default:
11161147
break drain
11171148
}
@@ -1124,6 +1155,12 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
11241155
agentDone = nil
11251156
continue
11261157
}
1158+
if !forwardedAny {
1159+
forwardSyntheticEvent(w, flusher, agent.ChatEvent{
1160+
Type: "error",
1161+
Data: map[string]any{"message": "agent finished without emitting a response"},
1162+
})
1163+
}
11271164
return
11281165
case <-agentCtx.Done():
11291166
// Safety net for the turnPending path above: bail out at
@@ -1142,6 +1179,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
11421179
continue
11431180
}
11441181
forwardEvent(w, flusher, env)
1182+
forwardedAny = true
11451183
if env.Event.Type == "done" {
11461184
return
11471185
}
@@ -1170,24 +1208,28 @@ func forwardEvent(w http.ResponseWriter, flusher http.Flusher, env agent.EventEn
11701208
flusher.Flush()
11711209
}
11721210

1211+
func forwardSyntheticEvent(w http.ResponseWriter, flusher http.Flusher, evt agent.ChatEvent) {
1212+
forwardEvent(w, flusher, agent.EventEnvelope{Seq: -1, Event: evt})
1213+
}
1214+
11731215
// handleChatSubscribe holds an SSE connection open for one (agent,
11741216
// session) pair and forwards three kinds of traffic:
11751217
//
1176-
// 1. Replay: session_events rows with seq > since (or > Last-Event-ID)
1177-
// that the client missed before connecting. Lets a freshly
1178-
// reloaded page pick up an in-flight turn without the rest of the
1179-
// reply disappearing.
1218+
// 1. Replay: session_events rows with seq > since (or > Last-Event-ID)
1219+
// that the client missed before connecting. Lets a freshly
1220+
// reloaded page pick up an in-flight turn without the rest of the
1221+
// reply disappearing.
11801222
//
1181-
// 2. Live agent chat events from the hub — every emitEvent call from
1182-
// the agent loop fans through here. This covers both the
1183-
// synchronous POST /api/chat/stream path AND turns started by
1184-
// other tabs / cron firings, so any open chat panel sees them
1185-
// regardless of who triggered the work.
1223+
// 2. Live agent chat events from the hub — every emitEvent call from
1224+
// the agent loop fans through here. This covers both the
1225+
// synchronous POST /api/chat/stream path AND turns started by
1226+
// other tabs / cron firings, so any open chat panel sees them
1227+
// regardless of who triggered the work.
11861228
//
1187-
// 3. Legacy WebChannel bus messages — cron-fired final replies that
1188-
// route through bus.Outbound rather than the chat-event path.
1189-
// Kept so we don't lose pre-existing functionality during the
1190-
// transition.
1229+
// 3. Legacy WebChannel bus messages — cron-fired final replies that
1230+
// route through bus.Outbound rather than the chat-event path.
1231+
// Kept so we don't lose pre-existing functionality during the
1232+
// transition.
11911233
//
11921234
// Auth gating reuses resolveAgent, so the caller must already have
11931235
// permission to chat with this agent. The subscription doesn't
@@ -1431,9 +1473,9 @@ func (s *Server) readWorkspaceFileBytes(ctx context.Context, agentID, relPath st
14311473
// parseTodoMarkdown extracts checkbox lines from a todo.md body and
14321474
// returns them as structured items. Conventions:
14331475
//
1434-
// - [ ] text → pending
1435-
// - [x] text → completed
1436-
// - [X] text → completed (case-insensitive)
1476+
// - [ ] text → pending
1477+
// - [x] text → completed
1478+
// - [X] text → completed (case-insensitive)
14371479
//
14381480
// Anything else (heading lines, blank lines, non-checkbox bullets) is
14391481
// ignored — todo.md doubles as a human-readable plan document, so we
@@ -1647,10 +1689,10 @@ func (s *Server) handleChats(w http.ResponseWriter, r *http.Request) {
16471689
}
16481690
totalPages := (total + pageSize - 1) / pageSize
16491691
jsonResponse(w, http.StatusOK, map[string]any{
1650-
"sessions": out,
1651-
"page": page,
1652-
"pageSize": pageSize,
1653-
"total": total,
1692+
"sessions": out,
1693+
"page": page,
1694+
"pageSize": pageSize,
1695+
"total": total,
16541696
"totalPages": totalPages,
16551697
})
16561698
}

0 commit comments

Comments
 (0)