Changeset 0.27.2 (#169)
This commit is contained in:
91
CHANGELOG.md
91
CHANGELOG.md
@@ -1,5 +1,96 @@
|
||||
# Changelog
|
||||
|
||||
## [0.27.2] — 2026-03-10
|
||||
|
||||
### Summary
|
||||
|
||||
Task execution — the scheduler can now actually run completions. Headless
|
||||
executor wires tasks into the full completion pipeline (provider resolution,
|
||||
tool execution, budget enforcement, notifications). Admin panel gains a Tasks
|
||||
section for CRUD, run history, kill switch, and global task configuration.
|
||||
|
||||
### Added
|
||||
|
||||
#### Core Tool Loop Extraction
|
||||
- **`CoreToolLoop` + `LoopSink` interface.** Single canonical implementation of
|
||||
multi-round LLM completion with tool execution. Replaces three duplicated
|
||||
implementations (SSE streaming, multi-model streaming, sync JSON). Budget
|
||||
enforcement built in: `MaxTokens`, `MaxToolCalls`, `MaxRounds`.
|
||||
- **`LoopResult.BudgetExceeded`** field tracks which limit was hit. Callers
|
||||
get budget status without inspecting loop internals.
|
||||
- **Four sink implementations:** `sseSink` (interactive SSE), `sseModelSink`
|
||||
(multi-model SSE), `accumSink` (sync JSON), `HeadlessSink` (scheduler).
|
||||
|
||||
#### Provider Resolution Extraction
|
||||
- **`ResolveProviderConfig()`** standalone function. Resolution chain:
|
||||
explicit config → channel config → owner's personal → global. Shared by
|
||||
completion handler and task executor.
|
||||
- **`BuildToolDefs()`** standalone function. Server-registered tools filtered
|
||||
by context predicates + browser extension tools + persona tool grant allowlist.
|
||||
- **`FilterToolDefsByGrants()`** for task-level tool grant narrowing.
|
||||
|
||||
#### Task Executor (`scheduler/executor.go`)
|
||||
- **Headless completion pipeline.** Resolves provider, builds messages + tools,
|
||||
runs `CoreToolLoop` with `Streaming: false`, persists assistant response in
|
||||
service channel, updates run record with tokens/tools/wall-clock, sends
|
||||
owner notification.
|
||||
- **Budget enforcement.** `max_tokens`, `max_tool_calls` from task definition.
|
||||
Wall clock via `context.WithTimeout`. Budget breach → `budget_exceeded`
|
||||
status + mandatory owner notification.
|
||||
- **Provider routing.** Task `provider_config_id` → channel → owner's
|
||||
personal BYOK → team → global. Full vault decryption path.
|
||||
- **Tool grants.** Persona grants + task-level grants applied as second-pass
|
||||
allowlist. No browser tools in headless mode.
|
||||
|
||||
#### Task Configuration (`taskutil/`)
|
||||
- **`LoadTaskConfig()`** reads from `global_settings` key `"tasks"`. Fields:
|
||||
`enabled`, `allow_personal`, `max_concurrent`, `default_max_tokens`,
|
||||
`default_max_tool_calls`, `default_max_wall_clock`.
|
||||
- **`ApplyDefaults()`** fills zero-value budget fields on new tasks.
|
||||
- **Full cron parsing** via `robfig/cron/v3`. Replaces hand-rolled
|
||||
`parseDailyCron`. Supports 5-field cron + descriptors (`@hourly`, `@daily`).
|
||||
- **`ValidateCron()`** pre-create validation rejects invalid expressions.
|
||||
|
||||
#### Permissions + Global Config
|
||||
- **`task.create`** permission — gates task create/update/delete/run routes.
|
||||
- **`task.admin`** permission — for future admin-level task management.
|
||||
- **Global config keys:** `tasks.enabled` (kill switch), `tasks.allow_personal`,
|
||||
`tasks.max_concurrent` (scheduler poll limit).
|
||||
|
||||
#### Admin Panel
|
||||
- **Tasks section** under Workflows category. Two tabs: task list + configuration.
|
||||
- **Task list:** all tasks across users with status, schedule, run count, next
|
||||
run time. Action buttons: run now, view history, delete.
|
||||
- **Run history:** drill-down per task showing start/end times, tokens used,
|
||||
tool calls, wall clock, status badge, error messages.
|
||||
- **Kill switch:** cancel active run from run history view.
|
||||
- **Configuration tab:** toggle tasks enabled/personal, set max concurrent and
|
||||
default budget ceilings. Writes to `global_settings` key `"tasks"`.
|
||||
|
||||
#### Routes
|
||||
- `POST /api/v1/tasks/:id/kill` — cancel active run (user + admin)
|
||||
- `POST /api/v1/admin/tasks/:id/run` — admin trigger
|
||||
- `POST /api/v1/admin/tasks/:id/kill` — admin kill
|
||||
- `DELETE /api/v1/admin/tasks/:id` — admin delete
|
||||
|
||||
### Changed
|
||||
- **`stream_loop.go`** rewritten from 559 lines to 181 — thin wrappers over
|
||||
`CoreToolLoop`. Identical behavior, zero duplication.
|
||||
- **`completion.go`** `syncCompletion` rewritten as wrapper. `resolveConfig`
|
||||
and `buildToolDefs` delegate to standalone functions. `-237 lines`.
|
||||
- **Scheduler startup** moved from early init to after hub + notification
|
||||
service initialization. Executor needs both.
|
||||
- **`scheduler.New()`** signature: `New(stores) → New(stores, executor)`.
|
||||
Executor is optional (nil = v0.27.1 behavior).
|
||||
- **Cron computation** in scheduler and task handler now uses `robfig/cron/v3`
|
||||
via `taskutil.NextRunFromSchedule`. Create and update validate schedule
|
||||
before persisting.
|
||||
|
||||
### Dependencies
|
||||
- Added `github.com/robfig/cron/v3 v3.0.1`
|
||||
|
||||
|
||||
|
||||
# Changelog Additions — v0.26.0
|
||||
|
||||
## [0.26.0] — 2026-03-10
|
||||
|
||||
@@ -162,7 +162,7 @@ v0.27.1 Tasks Foundation ✅
|
||||
(service channels, scheduler,
|
||||
task definitions, cron)
|
||||
│
|
||||
v0.27.2 Task Execution
|
||||
v0.27.2 Task Execution ✅
|
||||
(budgets, admin controls,
|
||||
run history, kill switch)
|
||||
│
|
||||
@@ -953,19 +953,23 @@ Depends on: v0.27.0 (debt clearance).
|
||||
|
||||
---
|
||||
|
||||
## v0.27.2 — Task Execution: Budgets + Admin Controls
|
||||
## v0.27.2 — Task Execution: Budgets + Admin Controls ✅
|
||||
|
||||
Guardrails for unattended execution.
|
||||
Depends on: v0.27.1 (task scheduler).
|
||||
|
||||
- [ ] Execution budget enforcement: `max_tokens`, `max_tool_calls`, `max_wall_clock`
|
||||
- [x] Execution budget enforcement: `max_tokens`, `max_tool_calls`, `max_wall_clock`
|
||||
- [x] ~~`task_runs` table + store~~ (shipped v0.27.1 — table, PG+SQLite store, run history API)
|
||||
- [ ] Budget breach → `budget_exceeded` status + owner notification
|
||||
- [x] Budget breach → `budget_exceeded` status + owner notification
|
||||
- [x] ~~Concurrent run skip~~ (shipped v0.27.1 — scheduler skips if `GetActiveRun` returns non-nil)
|
||||
- [ ] Global config: `tasks.enabled`, `tasks.allow_personal`, `tasks.max_concurrent`
|
||||
- [ ] Default budget ceilings in global config (overridable per task)
|
||||
- [ ] `tasks.create` and `tasks.admin` permissions
|
||||
- [ ] Admin panel → Tasks section (CRUD, history, kill switch, budget config)
|
||||
- [x] Global config: `tasks.enabled`, `tasks.allow_personal`, `tasks.max_concurrent`
|
||||
- [x] Default budget ceilings in global config (overridable per task)
|
||||
- [x] `tasks.create` and `tasks.admin` permissions
|
||||
- [x] Admin panel → Tasks section (CRUD, history, kill switch, budget config)
|
||||
- [x] Completion invocation from scheduler (headless executor via `CoreToolLoop`)
|
||||
- [x] Full cron parsing via `robfig/cron/v3` (replacing minimal parser)
|
||||
- [x] Provider resolution for task context (BYOK → team → global → routing policy)
|
||||
- [x] Kill switch: `POST /tasks/:id/kill` cancels active run
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ const (
|
||||
PermWorkflowCreate = "workflow.create" // create workflow definitions (v0.25.0)
|
||||
PermAdminView = "admin.view" // read-only admin panel access
|
||||
PermTokenUnlimited = "token.unlimited" // bypass token budgets
|
||||
PermTaskCreate = "task.create" // create scheduled tasks (v0.27.2)
|
||||
PermTaskAdmin = "task.admin" // manage all tasks, set global task config (v0.27.2)
|
||||
)
|
||||
|
||||
// AllPermissions is the complete set of valid permission strings.
|
||||
@@ -43,6 +45,8 @@ var AllPermissions = []string{
|
||||
PermWorkflowCreate,
|
||||
PermAdminView,
|
||||
PermTokenUnlimited,
|
||||
PermTaskCreate,
|
||||
PermTaskAdmin,
|
||||
}
|
||||
|
||||
// ── Resolution ──────────────────────────────
|
||||
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/minio/minio-go/v7 v7.0.82
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/net v0.30.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -857,97 +856,10 @@ func (h *CompletionHandler) multiModelStream(
|
||||
sendSSE("[DONE]")
|
||||
}
|
||||
|
||||
// buildToolDefs converts registered tools to provider-format tool definitions.
|
||||
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
|
||||
// Tools whose names appear in disabledTools are excluded.
|
||||
// v0.25.0: Tools self-declare availability via predicates on ToolContext.
|
||||
// v0.25.0: Persona tool grants apply as a second-pass allowlist when personaID
|
||||
// has explicit grants. Empty grants = inherit all (backward compat).
|
||||
// buildToolDefs delegates to the standalone BuildToolDefs function.
|
||||
// See resolve.go for the full implementation.
|
||||
func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool, disabledTools []string, tctx tools.ToolContext, personaID string) []providers.ToolDef {
|
||||
// Build disabled set for O(1) lookup
|
||||
disabled := make(map[string]bool, len(disabledTools))
|
||||
for _, name := range disabledTools {
|
||||
disabled[name] = true
|
||||
}
|
||||
|
||||
// v0.25.0: Tools self-declare availability via predicates.
|
||||
// AvailableFor evaluates each tool's Availability() against tctx,
|
||||
// then applies the disabled set. Replaces manual WorkspaceToolNames()
|
||||
// and GitToolNames() suppression.
|
||||
allTools := tools.AvailableFor(tctx, disabled)
|
||||
defs := make([]providers.ToolDef, 0, len(allTools))
|
||||
for _, t := range allTools {
|
||||
defs = append(defs, providers.ToolDef{
|
||||
Type: "function",
|
||||
Function: providers.FunctionDef{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Append browser-defined tool schemas from extensions
|
||||
if includeBrowser {
|
||||
exts, err := h.stores.Extensions.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
|
||||
return defs
|
||||
}
|
||||
for _, ext := range exts {
|
||||
if ext.Tier != "browser" {
|
||||
continue
|
||||
}
|
||||
var manifest struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters json.RawMessage `json:"parameters"`
|
||||
Tier string `json:"tier"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, t := range manifest.Tools {
|
||||
if disabled[t.Name] {
|
||||
continue
|
||||
}
|
||||
defs = append(defs, providers.ToolDef{
|
||||
Type: "function",
|
||||
Function: providers.FunctionDef{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v0.25.0: Persona tool grants — second-pass allowlist.
|
||||
// If the active persona has explicit tool grants, restrict to only those tools.
|
||||
// Empty grants = persona inherits all context-available tools (backward compat).
|
||||
if personaID != "" && h.stores.Personas != nil {
|
||||
grants, err := h.stores.Personas.GetToolGrants(ctx, personaID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to load tool grants for persona %s: %v", personaID, err)
|
||||
} else if len(grants) > 0 {
|
||||
allowed := make(map[string]bool, len(grants))
|
||||
for _, g := range grants {
|
||||
allowed[g] = true
|
||||
}
|
||||
filtered := defs[:0]
|
||||
for _, d := range defs {
|
||||
if allowed[d.Function.Name] {
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
}
|
||||
defs = filtered
|
||||
}
|
||||
}
|
||||
|
||||
return defs
|
||||
return BuildToolDefs(ctx, h.stores, userID, includeBrowser, disabledTools, tctx, personaID)
|
||||
}
|
||||
|
||||
// ── List Available Tools ────────────────────
|
||||
@@ -1017,8 +929,6 @@ func (h *CompletionHandler) ListTools(c *gin.Context) {
|
||||
|
||||
// ── Streaming Completion (SSE) with Tool Loop ──
|
||||
|
||||
const maxToolIterations = 10 // safety limit on tool call rounds
|
||||
|
||||
func (h *CompletionHandler) streamCompletion(
|
||||
c *gin.Context,
|
||||
provider providers.Provider,
|
||||
@@ -1071,125 +981,92 @@ func (h *CompletionHandler) syncCompletion(
|
||||
hooks.PreRequest(cfg, &req)
|
||||
}
|
||||
|
||||
var totalInput, totalOutput int
|
||||
var totalCacheCreation, totalCacheRead int
|
||||
var finalContent string
|
||||
var allToolActivity []map[string]interface{}
|
||||
|
||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||
callStart := time.Now()
|
||||
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
||||
h.recordHealth(configID, callStart, err)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
totalInput += resp.InputTokens
|
||||
totalOutput += resp.OutputTokens
|
||||
totalCacheCreation += resp.CacheCreationTokens
|
||||
totalCacheRead += resp.CacheReadTokens
|
||||
|
||||
// No tool calls — normal response
|
||||
if len(resp.ToolCalls) == 0 || resp.FinishReason != "tool_calls" {
|
||||
finalContent = resp.Content
|
||||
|
||||
// Persist assistant response
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity), configID, personaID); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// AI-to-AI chaining
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[chain] PANIC recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
h.chainIfMentioned(channelID, userID, personaID, finalContent, 0)
|
||||
}()
|
||||
|
||||
// Log usage
|
||||
h.logUsage(c, channelID, userID, configID, providerScope, model,
|
||||
totalInput, totalOutput, totalCacheCreation, totalCacheRead)
|
||||
|
||||
// Return OpenAI-compatible response
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": resp.Model,
|
||||
"choices": []gin.H{
|
||||
{
|
||||
"message": gin.H{
|
||||
"role": "assistant",
|
||||
"content": finalContent,
|
||||
},
|
||||
"finish_reason": resp.FinishReason,
|
||||
},
|
||||
},
|
||||
"usage": gin.H{
|
||||
"prompt_tokens": totalInput,
|
||||
"completion_tokens": totalOutput,
|
||||
"total_tokens": totalInput + totalOutput,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Tool execution round ────────────────
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: resp.Content,
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
|
||||
}
|
||||
req.Messages = append(req.Messages, assistantMsg)
|
||||
|
||||
execCtx := tools.ExecutionContext{
|
||||
result := CoreToolLoop(c.Request.Context(), LoopConfig{
|
||||
Provider: provider,
|
||||
Cfg: cfg,
|
||||
Req: &req,
|
||||
Model: model,
|
||||
ProviderType: providerID,
|
||||
ExecCtx: tools.ExecutionContext{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
PersonaID: personaID,
|
||||
WorkspaceID: workspaceID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
call := tools.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
}
|
||||
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
|
||||
result := tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
},
|
||||
Hub: h.hub,
|
||||
Health: h.health,
|
||||
ConfigID: configID,
|
||||
Budget: LoopBudget{},
|
||||
Streaming: false,
|
||||
}, accumSink{})
|
||||
|
||||
// Collect for persistence
|
||||
allToolActivity = append(allToolActivity, map[string]interface{}{
|
||||
"id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
"result": result.Content,
|
||||
"is_error": result.IsError,
|
||||
})
|
||||
|
||||
req.Messages = append(req.Messages, providers.Message{
|
||||
Role: "tool",
|
||||
Content: result.Content,
|
||||
ToolCallID: result.ToolCallID,
|
||||
Name: result.Name,
|
||||
})
|
||||
}
|
||||
// Provider error
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + result.Error.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Max iterations reached
|
||||
// Max iterations hit with no final content
|
||||
if result.BudgetExceeded == "max_rounds" && result.Content == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": model,
|
||||
"choices": []gin.H{
|
||||
{
|
||||
"message": gin.H{
|
||||
"role": "assistant",
|
||||
"content": "[Tool execution limit reached]",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
finalContent := result.Content
|
||||
|
||||
// Persist assistant response
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// AI-to-AI chaining
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[chain] PANIC recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
h.chainIfMentioned(channelID, userID, personaID, finalContent, 0)
|
||||
}()
|
||||
|
||||
// Log usage
|
||||
h.logUsage(c, channelID, userID, configID, providerScope, model,
|
||||
result.InputTokens, result.OutputTokens,
|
||||
result.CacheCreationTokens, result.CacheReadTokens)
|
||||
|
||||
// Return OpenAI-compatible response
|
||||
finishReason := "stop"
|
||||
if result.BudgetExceeded != "" {
|
||||
finishReason = "budget_exceeded"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": model,
|
||||
"choices": []gin.H{
|
||||
{
|
||||
"message": gin.H{
|
||||
"role": "assistant",
|
||||
"content": "[Tool execution limit reached]",
|
||||
"content": finalContent,
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"finish_reason": finishReason,
|
||||
},
|
||||
},
|
||||
"usage": gin.H{
|
||||
"prompt_tokens": result.InputTokens,
|
||||
"completion_tokens": result.OutputTokens,
|
||||
"total_tokens": result.InputTokens + result.OutputTokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1629,125 +1506,11 @@ func extractFirstMention(content string) string {
|
||||
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
|
||||
|
||||
func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, string, error) {
|
||||
var configID string
|
||||
|
||||
// 1. Explicit config from request
|
||||
if req.ProviderConfigID != "" {
|
||||
configID = req.ProviderConfigID
|
||||
}
|
||||
|
||||
// 2. Config from channel
|
||||
if configID == "" {
|
||||
var channelConfigID *string
|
||||
err := database.DB.QueryRow(
|
||||
database.Q(`SELECT provider_config_id FROM channels WHERE id = $1`), channelID,
|
||||
).Scan(&channelConfigID)
|
||||
if err == nil && channelConfigID != nil {
|
||||
configID = *channelConfigID
|
||||
}
|
||||
}
|
||||
|
||||
// 3. User's first active config (personal first, then global — excludes team providers)
|
||||
if configID == "" {
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT id FROM provider_configs
|
||||
WHERE is_active = true AND (
|
||||
(scope = 'personal' AND owner_id = $1)
|
||||
OR scope = 'global'
|
||||
)
|
||||
ORDER BY scope ASC, created_at ASC
|
||||
LIMIT 1
|
||||
`), userID).Scan(&configID)
|
||||
if err != nil {
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("no API config found — add one at /api-configs")
|
||||
}
|
||||
}
|
||||
|
||||
// Load the config — allow personal, global, OR team configs the user belongs to
|
||||
var providerID, endpoint string
|
||||
var providerScope string
|
||||
var modelDefault *string
|
||||
var apiKeyEnc, keyNonce []byte
|
||||
var keyScope string
|
||||
var customHeadersJSON, providerSettingsJSON []byte
|
||||
var proxyMode string
|
||||
var proxyURL *string
|
||||
// $2/userID appears twice in the query; Postgres reuses positional params,
|
||||
// SQLite needs each ? bound separately.
|
||||
configArgs := []interface{}{configID, userID}
|
||||
if database.IsSQLite() {
|
||||
configArgs = append(configArgs, userID)
|
||||
}
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
|
||||
model_default, headers, settings, COALESCE(proxy_mode, 'system'), proxy_url
|
||||
FROM provider_configs
|
||||
WHERE id = $1 AND is_active = true
|
||||
AND (scope = 'global'
|
||||
OR (scope = 'personal' AND owner_id = $2)
|
||||
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
|
||||
`), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
|
||||
&modelDefault, &customHeadersJSON, &providerSettingsJSON, &proxyMode, &proxyURL)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("API config not found or not accessible")
|
||||
}
|
||||
res, err := ResolveProviderConfig(h.vault, userID, channelID, req.ProviderConfigID, req.Model)
|
||||
if err != nil {
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("failed to load API config: %w", err)
|
||||
return providers.ProviderConfig{}, "", "", "", "", err
|
||||
}
|
||||
|
||||
// Resolve model: request > config default
|
||||
model := req.Model
|
||||
if model == "" && modelDefault != nil {
|
||||
model = *modelDefault
|
||||
}
|
||||
if model == "" {
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("no model specified and no default model in config")
|
||||
}
|
||||
|
||||
// Decrypt API key using the appropriate tier
|
||||
key := ""
|
||||
if len(apiKeyEnc) > 0 {
|
||||
if h.vault != nil {
|
||||
var err error
|
||||
key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, userID)
|
||||
if err != nil {
|
||||
if err == crypto.ErrVaultLocked {
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("personal vault is locked — please log in again")
|
||||
}
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("failed to decrypt API key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// No vault — key stored as raw bytes (unencrypted fallback)
|
||||
key = string(apiKeyEnc)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse custom headers
|
||||
customHeaders := make(map[string]string)
|
||||
if customHeadersJSON != nil {
|
||||
_ = json.Unmarshal(customHeadersJSON, &customHeaders)
|
||||
}
|
||||
|
||||
// Parse provider-specific settings
|
||||
providerSettings := make(map[string]interface{})
|
||||
if providerSettingsJSON != nil {
|
||||
_ = json.Unmarshal(providerSettingsJSON, &providerSettings)
|
||||
}
|
||||
|
||||
proxyURLStr := ""
|
||||
if proxyURL != nil {
|
||||
proxyURLStr = *proxyURL
|
||||
}
|
||||
|
||||
return providers.ProviderConfig{
|
||||
Endpoint: endpoint,
|
||||
APIKey: key,
|
||||
CustomHeaders: customHeaders,
|
||||
Settings: providerSettings,
|
||||
ProxyMode: proxyMode,
|
||||
ProxyURL: proxyURLStr,
|
||||
}, providerID, model, configID, providerScope, nil
|
||||
return res.Config, res.ProviderID, res.Model, res.ConfigID, res.ProviderScope, nil
|
||||
}
|
||||
|
||||
// ── Conversation Loader ─────────────────────
|
||||
|
||||
289
server/handlers/resolve.go
Normal file
289
server/handlers/resolve.go
Normal file
@@ -0,0 +1,289 @@
|
||||
// Package handlers — resolve.go
|
||||
//
|
||||
// v0.27.2: Extracted provider resolution and tool definition building
|
||||
// from CompletionHandler methods to standalone functions. Both the HTTP
|
||||
// completion handler and the headless task scheduler need these paths.
|
||||
//
|
||||
// The CompletionHandler methods (resolveConfig, buildToolDefs) remain as
|
||||
// thin wrappers for backward compat — existing callers are unchanged.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
|
||||
// ── Provider Resolution ────────────────────────
|
||||
|
||||
// ProviderResolution holds the resolved provider configuration and metadata.
|
||||
type ProviderResolution struct {
|
||||
Config providers.ProviderConfig
|
||||
ProviderID string // e.g. "anthropic", "openai"
|
||||
Model string // resolved model ID
|
||||
ConfigID string // provider_configs.id
|
||||
ProviderScope string // "personal", "team", "global"
|
||||
}
|
||||
|
||||
// ResolveProviderConfig resolves the provider configuration for a completion
|
||||
// request. Resolution order:
|
||||
// 1. Explicit providerConfigID (from request or task definition)
|
||||
// 2. Channel's configured provider (provider_config_id on channels table)
|
||||
// 3. User's first active config (personal first, then global)
|
||||
//
|
||||
// The vault is used to decrypt API keys. Pass nil for unencrypted fallback.
|
||||
func ResolveProviderConfig(
|
||||
vault *crypto.KeyResolver,
|
||||
userID, channelID, providerConfigID, modelID string,
|
||||
) (ProviderResolution, error) {
|
||||
configID := providerConfigID
|
||||
|
||||
// 2. Config from channel
|
||||
if configID == "" && channelID != "" {
|
||||
var channelConfigID *string
|
||||
err := database.DB.QueryRow(
|
||||
database.Q(`SELECT provider_config_id FROM channels WHERE id = $1`), channelID,
|
||||
).Scan(&channelConfigID)
|
||||
if err == nil && channelConfigID != nil {
|
||||
configID = *channelConfigID
|
||||
}
|
||||
}
|
||||
|
||||
// 3. User's first active config (personal first, then global — excludes team providers)
|
||||
if configID == "" {
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT id FROM provider_configs
|
||||
WHERE is_active = true AND (
|
||||
(scope = 'personal' AND owner_id = $1)
|
||||
OR scope = 'global'
|
||||
)
|
||||
ORDER BY scope ASC, created_at ASC
|
||||
LIMIT 1
|
||||
`), userID).Scan(&configID)
|
||||
if err != nil {
|
||||
return ProviderResolution{}, fmt.Errorf("no API config found — add one at /api-configs")
|
||||
}
|
||||
}
|
||||
|
||||
// Load the config — allow personal, global, OR team configs the user belongs to
|
||||
var providerID, endpoint string
|
||||
var providerScope string
|
||||
var modelDefault *string
|
||||
var apiKeyEnc, keyNonce []byte
|
||||
var keyScope string
|
||||
var customHeadersJSON, providerSettingsJSON []byte
|
||||
var proxyMode string
|
||||
var proxyURL *string
|
||||
|
||||
// $2/userID appears twice in the query; Postgres reuses positional params,
|
||||
// SQLite needs each ? bound separately.
|
||||
configArgs := []interface{}{configID, userID}
|
||||
if database.IsSQLite() {
|
||||
configArgs = append(configArgs, userID)
|
||||
}
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
|
||||
model_default, headers, settings, COALESCE(proxy_mode, 'system'), proxy_url
|
||||
FROM provider_configs
|
||||
WHERE id = $1 AND is_active = true
|
||||
AND (scope = 'global'
|
||||
OR (scope = 'personal' AND owner_id = $2)
|
||||
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
|
||||
`), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
|
||||
&modelDefault, &customHeadersJSON, &providerSettingsJSON, &proxyMode, &proxyURL)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return ProviderResolution{}, fmt.Errorf("API config not found or not accessible")
|
||||
}
|
||||
if err != nil {
|
||||
return ProviderResolution{}, fmt.Errorf("failed to load API config: %w", err)
|
||||
}
|
||||
|
||||
// Resolve model: explicit > config default
|
||||
model := modelID
|
||||
if model == "" && modelDefault != nil {
|
||||
model = *modelDefault
|
||||
}
|
||||
if model == "" {
|
||||
return ProviderResolution{}, fmt.Errorf("no model specified and no default model in config")
|
||||
}
|
||||
|
||||
// Decrypt API key using the appropriate tier
|
||||
key := ""
|
||||
if len(apiKeyEnc) > 0 {
|
||||
if vault != nil {
|
||||
var err error
|
||||
key, err = vault.Decrypt(apiKeyEnc, keyNonce, keyScope, userID)
|
||||
if err != nil {
|
||||
if err == crypto.ErrVaultLocked {
|
||||
return ProviderResolution{}, fmt.Errorf("personal vault is locked — please log in again")
|
||||
}
|
||||
return ProviderResolution{}, fmt.Errorf("failed to decrypt API key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// No vault — key stored as raw bytes (unencrypted fallback)
|
||||
key = string(apiKeyEnc)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse custom headers
|
||||
customHeaders := make(map[string]string)
|
||||
if customHeadersJSON != nil {
|
||||
_ = json.Unmarshal(customHeadersJSON, &customHeaders)
|
||||
}
|
||||
|
||||
// Parse provider-specific settings
|
||||
providerSettings := make(map[string]interface{})
|
||||
if providerSettingsJSON != nil {
|
||||
_ = json.Unmarshal(providerSettingsJSON, &providerSettings)
|
||||
}
|
||||
|
||||
proxyURLStr := ""
|
||||
if proxyURL != nil {
|
||||
proxyURLStr = *proxyURL
|
||||
}
|
||||
|
||||
return ProviderResolution{
|
||||
Config: providers.ProviderConfig{
|
||||
Endpoint: endpoint,
|
||||
APIKey: key,
|
||||
CustomHeaders: customHeaders,
|
||||
Settings: providerSettings,
|
||||
ProxyMode: proxyMode,
|
||||
ProxyURL: proxyURLStr,
|
||||
},
|
||||
ProviderID: providerID,
|
||||
Model: model,
|
||||
ConfigID: configID,
|
||||
ProviderScope: providerScope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Tool Definition Building ───────────────────
|
||||
|
||||
// BuildToolDefs assembles the tool definitions for a completion request.
|
||||
// Includes server-registered tools filtered by context predicates,
|
||||
// browser extension tools (if includeBrowser), and persona tool grant
|
||||
// allowlisting.
|
||||
//
|
||||
// Additional tool grant filtering (e.g. task-level grants) can be applied
|
||||
// by the caller after this function returns.
|
||||
func BuildToolDefs(
|
||||
ctx context.Context,
|
||||
stores store.Stores,
|
||||
userID string,
|
||||
includeBrowser bool,
|
||||
disabledTools []string,
|
||||
tctx tools.ToolContext,
|
||||
personaID string,
|
||||
) []providers.ToolDef {
|
||||
// Build disabled set for O(1) lookup
|
||||
disabled := make(map[string]bool, len(disabledTools))
|
||||
for _, name := range disabledTools {
|
||||
disabled[name] = true
|
||||
}
|
||||
|
||||
// v0.25.0: Tools self-declare availability via predicates.
|
||||
allTools := tools.AvailableFor(tctx, disabled)
|
||||
defs := make([]providers.ToolDef, 0, len(allTools))
|
||||
for _, t := range allTools {
|
||||
defs = append(defs, providers.ToolDef{
|
||||
Type: "function",
|
||||
Function: providers.FunctionDef{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Append browser-defined tool schemas from extensions
|
||||
if includeBrowser && stores.Extensions != nil {
|
||||
exts, err := stores.Extensions.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
|
||||
return defs
|
||||
}
|
||||
for _, ext := range exts {
|
||||
if ext.Tier != "browser" {
|
||||
continue
|
||||
}
|
||||
var manifest struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters json.RawMessage `json:"parameters"`
|
||||
Tier string `json:"tier"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, t := range manifest.Tools {
|
||||
if disabled[t.Name] {
|
||||
continue
|
||||
}
|
||||
defs = append(defs, providers.ToolDef{
|
||||
Type: "function",
|
||||
Function: providers.FunctionDef{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v0.25.0: Persona tool grants — second-pass allowlist.
|
||||
// If the active persona has explicit tool grants, restrict to only those tools.
|
||||
// Empty grants = persona inherits all context-available tools (backward compat).
|
||||
if personaID != "" && stores.Personas != nil {
|
||||
grants, err := stores.Personas.GetToolGrants(ctx, personaID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to load tool grants for persona %s: %v", personaID, err)
|
||||
} else if len(grants) > 0 {
|
||||
allowed := make(map[string]bool, len(grants))
|
||||
for _, g := range grants {
|
||||
allowed[g] = true
|
||||
}
|
||||
filtered := defs[:0]
|
||||
for _, d := range defs {
|
||||
if allowed[d.Function.Name] {
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
}
|
||||
defs = filtered
|
||||
}
|
||||
}
|
||||
|
||||
return defs
|
||||
}
|
||||
|
||||
// FilterToolDefsByGrants applies an additional allowlist to tool defs.
|
||||
// Used by the task scheduler to enforce task-level tool grants on top
|
||||
// of persona-level grants. Passing nil or empty grants returns defs unchanged.
|
||||
func FilterToolDefsByGrants(defs []providers.ToolDef, grants []string) []providers.ToolDef {
|
||||
if len(grants) == 0 {
|
||||
return defs
|
||||
}
|
||||
allowed := make(map[string]bool, len(grants))
|
||||
for _, g := range grants {
|
||||
allowed[g] = true
|
||||
}
|
||||
filtered := make([]providers.ToolDef, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
if allowed[d.Function.Name] {
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -17,17 +16,6 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
|
||||
// streamResult holds the accumulated output from a streaming completion
|
||||
// with tool execution. Callers are responsible for persistence.
|
||||
type streamResult struct {
|
||||
Content string
|
||||
ToolActivity []map[string]interface{}
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
CacheCreationTokens int
|
||||
CacheReadTokens int
|
||||
}
|
||||
|
||||
// recordHealthFn records provider health from standalone streaming functions.
|
||||
func recordHealthFn(hr HealthRecorder, configID string, start time.Time, err error) {
|
||||
if hr == nil || configID == "" {
|
||||
@@ -46,17 +34,13 @@ func recordHealthFn(hr HealthRecorder, configID string, start time.Time, err err
|
||||
}
|
||||
}
|
||||
|
||||
// streamWithToolLoop is the single, canonical streaming implementation.
|
||||
// It handles SSE setup, multi-round tool execution, reasoning_content
|
||||
// forwarding, and client notifications. Returns the accumulated content
|
||||
// and tool activity for the caller to persist however it needs to.
|
||||
// streamWithToolLoop is the SSE streaming entry point for single-model
|
||||
// completion with tool execution. Sets SSE headers, delegates to
|
||||
// CoreToolLoop with an sseSink, and returns the accumulated result.
|
||||
//
|
||||
// Used by:
|
||||
// - streamCompletion (normal chat — persists via persistMessage)
|
||||
// - Regenerate (regen/edit — persists as sibling with tree metadata)
|
||||
//
|
||||
// If you add features here (new event types, new tool capabilities, new
|
||||
// persistence fields), both callers get them automatically.
|
||||
func streamWithToolLoop(
|
||||
c *gin.Context,
|
||||
provider providers.Provider,
|
||||
@@ -65,244 +49,42 @@ func streamWithToolLoop(
|
||||
model, providerType, userID, channelID, personaID, workspaceID, configID, teamID string,
|
||||
hub *events.Hub,
|
||||
health HealthRecorder,
|
||||
) streamResult {
|
||||
) LoopResult {
|
||||
// Set SSE headers
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
|
||||
flush := func() {
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
sink := newSSESink(c, model)
|
||||
|
||||
sendSSE := func(data string) {
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
|
||||
flush()
|
||||
}
|
||||
|
||||
sendEvent := func(event, data string) {
|
||||
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
|
||||
flush()
|
||||
}
|
||||
|
||||
var result streamResult
|
||||
|
||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||
callStart := time.Now()
|
||||
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, *req)
|
||||
if err != nil {
|
||||
recordHealthFn(health, configID, callStart, err)
|
||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
|
||||
return result
|
||||
}
|
||||
|
||||
var iterContent string
|
||||
var iterReasoning string
|
||||
var toolCalls []providers.ToolCall
|
||||
|
||||
for event := range ch {
|
||||
// Apply provider-specific post-processing hooks (v0.22.1)
|
||||
if hooks := providers.GetHooks(providerType); hooks != nil {
|
||||
hooks.PostStreamEvent(cfg, &event)
|
||||
}
|
||||
|
||||
if event.Error != nil {
|
||||
recordHealthFn(health, configID, callStart, event.Error)
|
||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
|
||||
return result
|
||||
}
|
||||
|
||||
// Stream reasoning deltas to client
|
||||
if event.Reasoning != "" {
|
||||
iterReasoning += event.Reasoning
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q}`,
|
||||
event.Reasoning, model))
|
||||
}
|
||||
|
||||
// Stream text deltas to client
|
||||
if event.Delta != "" {
|
||||
iterContent += event.Delta
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`,
|
||||
event.Delta, model))
|
||||
}
|
||||
|
||||
if event.Done {
|
||||
recordHealthFn(health, configID, callStart, nil)
|
||||
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
|
||||
toolCalls = event.ToolCalls
|
||||
// Accumulate tokens from this tool-call iteration
|
||||
result.InputTokens += event.InputTokens
|
||||
result.OutputTokens += event.OutputTokens
|
||||
result.CacheCreationTokens += event.CacheCreationTokens
|
||||
result.CacheReadTokens += event.CacheReadTokens
|
||||
} else {
|
||||
// Normal completion — send finish event
|
||||
finishReason := event.FinishReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
if iterReasoning != "" {
|
||||
result.Content += "<think>" + iterReasoning + "</think>"
|
||||
}
|
||||
result.Content += iterContent
|
||||
// Accumulate final tokens
|
||||
result.InputTokens += event.InputTokens
|
||||
result.OutputTokens += event.OutputTokens
|
||||
result.CacheCreationTokens += event.CacheCreationTokens
|
||||
result.CacheReadTokens += event.CacheReadTokens
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
|
||||
finishReason, model))
|
||||
sendSSE("[DONE]")
|
||||
return result
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool execution round ────────────────
|
||||
if len(toolCalls) == 0 {
|
||||
// Stream ended without tool calls or finish — shouldn't happen
|
||||
sendSSE("[DONE]")
|
||||
if iterReasoning != "" {
|
||||
result.Content += "<think>" + iterReasoning + "</think>"
|
||||
}
|
||||
result.Content += iterContent
|
||||
return result
|
||||
}
|
||||
|
||||
// Notify client about tool calls
|
||||
toolCallsJSON, _ := json.Marshal(toolCalls)
|
||||
sendEvent("tool_use", string(toolCallsJSON))
|
||||
|
||||
// Append assistant message (with tool_calls, possibly with text) to conversation
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: iterContent,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
|
||||
}
|
||||
req.Messages = append(req.Messages, assistantMsg)
|
||||
|
||||
// Execute all tool calls
|
||||
execCtx := tools.ExecutionContext{
|
||||
return CoreToolLoop(c.Request.Context(), LoopConfig{
|
||||
Provider: provider,
|
||||
Cfg: cfg,
|
||||
Req: req,
|
||||
Model: model,
|
||||
ProviderType: providerType,
|
||||
ExecCtx: tools.ExecutionContext{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
PersonaID: personaID,
|
||||
WorkspaceID: workspaceID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
call := tools.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
}
|
||||
|
||||
var toolResult tools.ToolResult
|
||||
|
||||
// Check if tool is registered locally (server-side)
|
||||
toolStart := time.Now()
|
||||
if tools.Get(call.Name) != nil {
|
||||
log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID)
|
||||
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
// Record tool health (v0.22.4)
|
||||
if health != nil {
|
||||
toolLatency := int(time.Since(toolStart).Milliseconds())
|
||||
if toolResult.IsError {
|
||||
health.RecordToolError(call.Name, toolLatency, toolResult.Content)
|
||||
} else {
|
||||
health.RecordToolSuccess(call.Name, toolLatency)
|
||||
}
|
||||
}
|
||||
} else if hub != nil && hub.IsConnected(userID) {
|
||||
// Route to browser via WebSocket bridge
|
||||
log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID)
|
||||
toolResult = executeBrowserTool(hub, userID, call)
|
||||
} else {
|
||||
log.Printf("⚠️ Unknown tool: %s (call %s)", call.Name, call.ID)
|
||||
toolResult = tools.ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: `{"error":"unknown tool or browser not connected"}`,
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Notify client about tool result
|
||||
resultJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"tool_call_id": toolResult.ToolCallID,
|
||||
"name": toolResult.Name,
|
||||
"content": toolResult.Content,
|
||||
"is_error": toolResult.IsError,
|
||||
})
|
||||
sendEvent("tool_result", string(resultJSON))
|
||||
|
||||
// Emit workspace.file.changed for live editor updates (v0.21.5)
|
||||
if hub != nil && !toolResult.IsError && workspaceID != "" {
|
||||
if call.Name == "workspace_write" || call.Name == "workspace_patch" {
|
||||
var toolArgs struct{ Path string `json:"path"` }
|
||||
if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" {
|
||||
hub.SendToUser(userID, events.Event{
|
||||
Label: "workspace.file.changed",
|
||||
Payload: events.MustJSON(map[string]string{
|
||||
"workspace_id": workspaceID,
|
||||
"path": toolArgs.Path,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect for persistence
|
||||
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
|
||||
"id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
"result": toolResult.Content,
|
||||
"is_error": toolResult.IsError,
|
||||
})
|
||||
|
||||
// Append tool result to conversation for next iteration
|
||||
req.Messages = append(req.Messages, providers.Message{
|
||||
Role: "tool",
|
||||
Content: toolResult.Content,
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
Name: toolResult.Name,
|
||||
})
|
||||
}
|
||||
|
||||
result.Content += iterContent
|
||||
// Loop: send updated messages back to provider
|
||||
}
|
||||
|
||||
// Hit max iterations
|
||||
log.Printf("⚠️ Tool loop hit max iterations (%d) for channel %s", maxToolIterations, channelID)
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`,
|
||||
escapeJSON("[Tool execution limit reached]"), model))
|
||||
sendSSE("[DONE]")
|
||||
|
||||
return result
|
||||
},
|
||||
Hub: hub,
|
||||
Health: health,
|
||||
ConfigID: configID,
|
||||
Budget: LoopBudget{},
|
||||
Streaming: true,
|
||||
}, sink)
|
||||
}
|
||||
|
||||
const browserToolTimeout = 30 * time.Second
|
||||
|
||||
// streamModelResponse is a variant of streamWithToolLoop used by multi-model
|
||||
// fan-out (v0.20.0). It streams a single model's response within an already-
|
||||
// established SSE connection — it does NOT set SSE headers or send [DONE].
|
||||
//
|
||||
// SSE deltas include a "model_display" field so the frontend can attribute
|
||||
// each response to the correct model.
|
||||
// streamModelResponse is the multi-model streaming variant. Runs on an
|
||||
// already-established SSE connection — does NOT set SSE headers or send
|
||||
// [DONE]. SSE deltas include a "model_display" field for frontend attribution.
|
||||
func streamModelResponse(
|
||||
c *gin.Context,
|
||||
provider providers.Provider,
|
||||
@@ -311,187 +93,28 @@ func streamModelResponse(
|
||||
model, providerType, displayName, userID, channelID, personaID, workspaceID, configID, teamID string,
|
||||
hub *events.Hub,
|
||||
health HealthRecorder,
|
||||
) streamResult {
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
flush := func() {
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
sendSSE := func(data string) {
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
|
||||
flush()
|
||||
}
|
||||
sendEvent := func(event, data string) {
|
||||
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
|
||||
flush()
|
||||
}
|
||||
) LoopResult {
|
||||
sink := newSSEModelSink(c, model, displayName)
|
||||
|
||||
var result streamResult
|
||||
|
||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||
callStart := time.Now()
|
||||
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, *req)
|
||||
if err != nil {
|
||||
recordHealthFn(health, configID, callStart, err)
|
||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
|
||||
return result
|
||||
}
|
||||
|
||||
var iterContent string
|
||||
var iterReasoning string
|
||||
var toolCalls []providers.ToolCall
|
||||
|
||||
for event := range ch {
|
||||
// Apply provider-specific post-processing hooks (v0.22.1)
|
||||
if hooks := providers.GetHooks(providerType); hooks != nil {
|
||||
hooks.PostStreamEvent(cfg, &event)
|
||||
}
|
||||
|
||||
if event.Error != nil {
|
||||
recordHealthFn(health, configID, callStart, event.Error)
|
||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
|
||||
return result
|
||||
}
|
||||
|
||||
if event.Reasoning != "" {
|
||||
iterReasoning += event.Reasoning
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`,
|
||||
event.Reasoning, model, displayName))
|
||||
}
|
||||
|
||||
if event.Delta != "" {
|
||||
iterContent += event.Delta
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`,
|
||||
event.Delta, model, displayName))
|
||||
}
|
||||
|
||||
if event.Done {
|
||||
recordHealthFn(health, configID, callStart, nil)
|
||||
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
|
||||
toolCalls = event.ToolCalls
|
||||
result.InputTokens += event.InputTokens
|
||||
result.OutputTokens += event.OutputTokens
|
||||
result.CacheCreationTokens += event.CacheCreationTokens
|
||||
result.CacheReadTokens += event.CacheReadTokens
|
||||
} else {
|
||||
finishReason := event.FinishReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
if iterReasoning != "" {
|
||||
result.Content += "<think>" + iterReasoning + "</think>"
|
||||
}
|
||||
result.Content += iterContent
|
||||
result.InputTokens += event.InputTokens
|
||||
result.OutputTokens += event.OutputTokens
|
||||
result.CacheCreationTokens += event.CacheCreationTokens
|
||||
result.CacheReadTokens += event.CacheReadTokens
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q,"model_display":%q}`,
|
||||
finishReason, model, displayName))
|
||||
// Do NOT send [DONE] — caller manages that for multi-model
|
||||
return result
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Tool execution (same logic as streamWithToolLoop)
|
||||
if len(toolCalls) == 0 {
|
||||
if iterReasoning != "" {
|
||||
result.Content += "<think>" + iterReasoning + "</think>"
|
||||
}
|
||||
result.Content += iterContent
|
||||
return result
|
||||
}
|
||||
|
||||
toolCallsJSON, _ := json.Marshal(toolCalls)
|
||||
sendEvent("tool_use", string(toolCallsJSON))
|
||||
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: iterContent,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
|
||||
}
|
||||
req.Messages = append(req.Messages, assistantMsg)
|
||||
|
||||
execCtx := tools.ExecutionContext{
|
||||
return CoreToolLoop(c.Request.Context(), LoopConfig{
|
||||
Provider: provider,
|
||||
Cfg: cfg,
|
||||
Req: req,
|
||||
Model: model,
|
||||
ProviderType: providerType,
|
||||
ExecCtx: tools.ExecutionContext{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
PersonaID: personaID,
|
||||
WorkspaceID: workspaceID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
call := tools.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
}
|
||||
|
||||
var toolResult tools.ToolResult
|
||||
toolStart := time.Now()
|
||||
if tools.Get(call.Name) != nil {
|
||||
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
// Record tool health (v0.22.4)
|
||||
if health != nil {
|
||||
toolLatency := int(time.Since(toolStart).Milliseconds())
|
||||
if toolResult.IsError {
|
||||
health.RecordToolError(call.Name, toolLatency, toolResult.Content)
|
||||
} else {
|
||||
health.RecordToolSuccess(call.Name, toolLatency)
|
||||
}
|
||||
}
|
||||
} else if hub != nil && hub.IsConnected(userID) {
|
||||
toolResult = executeBrowserTool(hub, userID, call)
|
||||
} else {
|
||||
toolResult = tools.ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: `{"error":"unknown tool or browser not connected"}`,
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"tool_call_id": toolResult.ToolCallID,
|
||||
"name": toolResult.Name,
|
||||
"content": toolResult.Content,
|
||||
"is_error": toolResult.IsError,
|
||||
})
|
||||
sendEvent("tool_result", string(resultJSON))
|
||||
|
||||
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
|
||||
"id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
"result": toolResult.Content,
|
||||
"is_error": toolResult.IsError,
|
||||
})
|
||||
|
||||
req.Messages = append(req.Messages, providers.Message{
|
||||
Role: "tool",
|
||||
Content: toolResult.Content,
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
Name: toolResult.Name,
|
||||
})
|
||||
}
|
||||
|
||||
result.Content += iterContent
|
||||
}
|
||||
|
||||
// Hit max iterations
|
||||
log.Printf("⚠️ Multi-model tool loop hit max iterations (%d) for model %s", maxToolIterations, displayName)
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q,"model_display":%q}`,
|
||||
escapeJSON("[Tool execution limit reached]"), model, displayName))
|
||||
|
||||
return result
|
||||
},
|
||||
Hub: hub,
|
||||
Health: health,
|
||||
ConfigID: configID,
|
||||
Budget: LoopBudget{},
|
||||
Streaming: true,
|
||||
}, sink)
|
||||
}
|
||||
|
||||
// executeBrowserTool sends a tool call to the user's browser via WebSocket
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,15 @@ func (h *TaskHandler) ListAll(c *gin.Context) {
|
||||
// Create creates a new task.
|
||||
// POST /api/v1/tasks
|
||||
func (h *TaskHandler) Create(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// v0.27.2: Check global task configuration
|
||||
taskCfg := taskutil.LoadTaskConfig(ctx, h.stores.GlobalConfig)
|
||||
if !taskCfg.Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "tasks are disabled by the administrator"})
|
||||
return
|
||||
}
|
||||
|
||||
var t models.Task
|
||||
if err := c.ShouldBindJSON(&t); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -59,6 +69,14 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
|
||||
t.OwnerID = c.GetString("user_id")
|
||||
|
||||
// v0.27.2: Personal task check — non-admin users need tasks.allow_personal
|
||||
if t.Scope == "personal" || t.Scope == "" {
|
||||
if c.GetString("role") != "admin" && !taskCfg.AllowPersonal {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "personal tasks are disabled — contact your administrator"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if t.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
@@ -77,6 +95,12 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// v0.27.2: Validate cron expression before persisting
|
||||
if err := taskutil.ValidateCron(t.Schedule); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Defaults
|
||||
if t.Scope == "" {
|
||||
t.Scope = "personal"
|
||||
@@ -90,29 +114,22 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
if t.OutputMode == "" {
|
||||
t.OutputMode = "channel"
|
||||
}
|
||||
if t.MaxTokens == 0 {
|
||||
t.MaxTokens = 4096
|
||||
}
|
||||
if t.MaxToolCalls == 0 {
|
||||
t.MaxToolCalls = 10
|
||||
}
|
||||
if t.MaxWallClock == 0 {
|
||||
t.MaxWallClock = 300
|
||||
}
|
||||
|
||||
// v0.27.2: Apply global default budgets for zero-value fields
|
||||
taskCfg.ApplyDefaults(&t)
|
||||
|
||||
// Compute initial next_run_at
|
||||
// For "once" tasks, next_run_at = now (runs on next poll)
|
||||
// For cron tasks, compute from schedule
|
||||
if t.Schedule == "once" {
|
||||
now := time.Now().UTC()
|
||||
t.NextRunAt = &now
|
||||
} else {
|
||||
// v0.27.2: Full cron parsing via robfig/cron/v3
|
||||
t.NextRunAt = taskutil.NextRunFromSchedule(t.Schedule, t.Timezone)
|
||||
}
|
||||
// TODO: compute next_run_at from cron expression (requires robfig/cron/v3)
|
||||
// For now, "once" tasks run immediately; cron tasks need next_run_at set by scheduler
|
||||
|
||||
t.IsActive = true
|
||||
|
||||
if err := h.stores.Tasks.Create(c.Request.Context(), &t); err != nil {
|
||||
if err := h.stores.Tasks.Create(ctx, &t); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task: " + err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -160,12 +177,29 @@ func (h *TaskHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// v0.27.2: Validate new schedule if provided
|
||||
if patch.Schedule != nil {
|
||||
if err := taskutil.ValidateCron(*patch.Schedule); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Tasks.Update(ctx, id, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task"})
|
||||
return
|
||||
}
|
||||
|
||||
updated, _ := h.stores.Tasks.GetByID(ctx, id)
|
||||
|
||||
// Recompute next_run_at if schedule or timezone changed
|
||||
if patch.Schedule != nil || patch.Timezone != nil {
|
||||
tz := updated.Timezone
|
||||
next := taskutil.NextRunFromSchedule(updated.Schedule, tz)
|
||||
_ = h.stores.Tasks.SetNextRun(ctx, id, next)
|
||||
updated.NextRunAt = next
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
@@ -213,6 +247,13 @@ func (h *TaskHandler) RunNow(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// v0.27.2: Check tasks enabled
|
||||
taskCfg := taskutil.LoadTaskConfig(ctx, h.stores.GlobalConfig)
|
||||
if !taskCfg.Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "tasks are disabled by the administrator"})
|
||||
return
|
||||
}
|
||||
|
||||
t, err := h.stores.Tasks.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
|
||||
@@ -238,3 +279,33 @@ func (h *TaskHandler) RunNow(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"scheduled": true, "next_run_at": now})
|
||||
}
|
||||
|
||||
// KillRun cancels the active run of a task.
|
||||
// POST /api/v1/tasks/:id/kill
|
||||
func (h *TaskHandler) KillRun(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
t, err := h.stores.Tasks.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
|
||||
return
|
||||
}
|
||||
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
|
||||
return
|
||||
}
|
||||
|
||||
active, _ := h.stores.Tasks.GetActiveRun(ctx, id)
|
||||
if active == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no active run to cancel"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Tasks.UpdateRun(ctx, active.ID, "cancelled", active.TokensUsed, active.ToolCalls, active.WallClock, "killed by user"); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel run"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"killed": true, "run_id": active.ID})
|
||||
}
|
||||
|
||||
612
server/handlers/tool_loop.go
Normal file
612
server/handlers/tool_loop.go
Normal file
@@ -0,0 +1,612 @@
|
||||
// Package handlers — tool_loop.go
|
||||
//
|
||||
// v0.27.2: Extracted core tool loop with sink abstraction.
|
||||
//
|
||||
// coreToolLoop is the single implementation of multi-round LLM completion
|
||||
// with tool execution. All callers (SSE streaming, multi-model streaming,
|
||||
// sync JSON, headless scheduler) provide a LoopSink for I/O and a LoopConfig
|
||||
// for dependencies. The loop handles provider calls, tool dispatch (server +
|
||||
// browser bridge), health recording, budget enforcement, and workspace events.
|
||||
//
|
||||
// Prior to this extraction, the same logic was duplicated in three places:
|
||||
// - streamWithToolLoop (SSE streaming)
|
||||
// - streamModelResponse (multi-model SSE)
|
||||
// - syncCompletion (non-streaming JSON)
|
||||
//
|
||||
// Those functions are now thin wrappers over coreToolLoop.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
|
||||
// ── Types ──────────────────────────────────────
|
||||
|
||||
// defaultMaxRounds is the safety limit on tool call iterations.
|
||||
// Callers can override via LoopBudget.MaxRounds.
|
||||
const defaultMaxRounds = 10
|
||||
|
||||
// LoopResult holds the accumulated output from a completion with tool
|
||||
// execution. Callers are responsible for persistence.
|
||||
type LoopResult struct {
|
||||
Content string
|
||||
ToolActivity []map[string]interface{}
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
CacheCreationTokens int
|
||||
CacheReadTokens int
|
||||
ToolCallCount int // total tool calls across all rounds
|
||||
BudgetExceeded string // "" | "tokens" | "tool_calls" | "max_rounds"
|
||||
Error error // non-nil if the loop terminated due to a provider error
|
||||
}
|
||||
|
||||
// LoopBudget controls resource limits for the tool loop.
|
||||
// Zero values mean "use default" for MaxRounds and "unlimited" for the rest.
|
||||
type LoopBudget struct {
|
||||
MaxRounds int // max tool-call iterations; 0 = defaultMaxRounds
|
||||
MaxToolCalls int // total tool calls across all rounds; 0 = unlimited
|
||||
MaxTokens int // total input+output tokens; 0 = unlimited
|
||||
}
|
||||
|
||||
func (b LoopBudget) maxRounds() int {
|
||||
if b.MaxRounds > 0 {
|
||||
return b.MaxRounds
|
||||
}
|
||||
return defaultMaxRounds
|
||||
}
|
||||
|
||||
// LoopConfig bundles all dependencies for the core tool loop.
|
||||
type LoopConfig struct {
|
||||
Provider providers.Provider
|
||||
Cfg providers.ProviderConfig
|
||||
Req *providers.CompletionRequest
|
||||
Model string
|
||||
ProviderType string
|
||||
ExecCtx tools.ExecutionContext
|
||||
Hub *events.Hub // nil for headless (scheduler)
|
||||
Health HealthRecorder // nil for headless
|
||||
ConfigID string
|
||||
Budget LoopBudget
|
||||
Streaming bool // true = StreamCompletion, false = ChatCompletion
|
||||
}
|
||||
|
||||
// ── Sink Interface ─────────────────────────────
|
||||
|
||||
// LoopSink receives events from the core tool loop for I/O.
|
||||
// Implementations control what happens with each event: write SSE to
|
||||
// a gin.Context, accumulate silently, log to stdout, etc.
|
||||
type LoopSink interface {
|
||||
// OnDelta receives a text content delta from the LLM.
|
||||
OnDelta(delta string)
|
||||
|
||||
// OnReasoning receives a reasoning/thinking delta from the LLM.
|
||||
OnReasoning(delta string)
|
||||
|
||||
// OnError receives a fatal error (provider failure, stream error).
|
||||
OnError(err error)
|
||||
|
||||
// OnToolUse is called when the LLM requests tool calls.
|
||||
OnToolUse(calls []providers.ToolCall)
|
||||
|
||||
// OnToolResult is called after each tool execution completes.
|
||||
OnToolResult(result map[string]interface{})
|
||||
|
||||
// OnFinish is called when the LLM produces a finish_reason.
|
||||
OnFinish(reason string)
|
||||
|
||||
// OnDone signals end of the entire completion (after finish or budget breach).
|
||||
OnDone()
|
||||
|
||||
// OnMaxRounds is called when the loop hits its iteration limit.
|
||||
OnMaxRounds()
|
||||
}
|
||||
|
||||
// ── Core Tool Loop ─────────────────────────────
|
||||
|
||||
// coreToolLoop is the single, canonical tool-loop implementation.
|
||||
// It drives multi-round LLM completion with tool execution, budget
|
||||
// enforcement, and health recording. I/O is delegated to the sink.
|
||||
//
|
||||
// The caller is responsible for:
|
||||
// - Setting SSE headers (if streaming to a client)
|
||||
// - Calling providers.GetHooks().PreRequest() before entry
|
||||
// - Persisting the result after return
|
||||
// - Sending [DONE] or HTTP response after return (via sink.OnDone)
|
||||
func CoreToolLoop(ctx context.Context, lcfg LoopConfig, sink LoopSink) LoopResult {
|
||||
var result LoopResult
|
||||
maxRounds := lcfg.Budget.maxRounds()
|
||||
|
||||
for iteration := 0; iteration < maxRounds; iteration++ {
|
||||
var iterContent string
|
||||
var iterReasoning string
|
||||
var toolCalls []providers.ToolCall
|
||||
var iterInput, iterOutput, iterCacheCreate, iterCacheRead int
|
||||
|
||||
if lcfg.Streaming {
|
||||
done := runStreamingRound(ctx, lcfg, sink, &result,
|
||||
&iterContent, &iterReasoning, &toolCalls,
|
||||
&iterInput, &iterOutput, &iterCacheCreate, &iterCacheRead)
|
||||
if done {
|
||||
return result
|
||||
}
|
||||
} else {
|
||||
done := runSyncRound(ctx, lcfg, sink, &result,
|
||||
&iterContent, &iterReasoning, &toolCalls,
|
||||
&iterInput, &iterOutput, &iterCacheCreate, &iterCacheRead)
|
||||
if done {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool execution round ────────────────
|
||||
if len(toolCalls) == 0 {
|
||||
// Stream/call ended without tool calls or finish — edge case
|
||||
if iterReasoning != "" {
|
||||
result.Content += "<think>" + iterReasoning + "</think>"
|
||||
}
|
||||
result.Content += iterContent
|
||||
sink.OnDone()
|
||||
return result
|
||||
}
|
||||
|
||||
// Budget check: tool calls (pre-execution)
|
||||
result.ToolCallCount += len(toolCalls)
|
||||
if lcfg.Budget.MaxToolCalls > 0 && result.ToolCallCount > lcfg.Budget.MaxToolCalls {
|
||||
result.BudgetExceeded = "tool_calls"
|
||||
result.Content += iterContent
|
||||
sink.OnFinish("budget_exceeded")
|
||||
sink.OnDone()
|
||||
return result
|
||||
}
|
||||
|
||||
sink.OnToolUse(toolCalls)
|
||||
|
||||
// Append assistant message (with tool_calls, possibly with text) to conversation
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: iterContent,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
|
||||
}
|
||||
lcfg.Req.Messages = append(lcfg.Req.Messages, assistantMsg)
|
||||
|
||||
// Execute all tool calls
|
||||
for _, tc := range toolCalls {
|
||||
call := tools.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
}
|
||||
|
||||
toolResult := executeToolCall(ctx, lcfg, call)
|
||||
|
||||
// Notify sink
|
||||
resultMap := map[string]interface{}{
|
||||
"tool_call_id": toolResult.ToolCallID,
|
||||
"name": toolResult.Name,
|
||||
"content": toolResult.Content,
|
||||
"is_error": toolResult.IsError,
|
||||
}
|
||||
sink.OnToolResult(resultMap)
|
||||
|
||||
// Emit workspace.file.changed for live editor updates (v0.21.5)
|
||||
emitWorkspaceEvent(lcfg, call, toolResult)
|
||||
|
||||
// Collect for persistence
|
||||
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
|
||||
"id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
"result": toolResult.Content,
|
||||
"is_error": toolResult.IsError,
|
||||
})
|
||||
|
||||
// Append tool result to conversation for next iteration
|
||||
lcfg.Req.Messages = append(lcfg.Req.Messages, providers.Message{
|
||||
Role: "tool",
|
||||
Content: toolResult.Content,
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
Name: toolResult.Name,
|
||||
})
|
||||
}
|
||||
|
||||
result.Content += iterContent
|
||||
|
||||
// Budget check: tokens (post-round)
|
||||
if lcfg.Budget.MaxTokens > 0 && (result.InputTokens+result.OutputTokens) > lcfg.Budget.MaxTokens {
|
||||
result.BudgetExceeded = "tokens"
|
||||
sink.OnFinish("budget_exceeded")
|
||||
sink.OnDone()
|
||||
return result
|
||||
}
|
||||
|
||||
// Loop: send updated messages back to provider
|
||||
}
|
||||
|
||||
// Hit max iterations
|
||||
result.BudgetExceeded = "max_rounds"
|
||||
sink.OnMaxRounds()
|
||||
sink.OnDone()
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Streaming Round ────────────────────────────
|
||||
|
||||
// runStreamingRound executes one provider StreamCompletion call and
|
||||
// processes the event stream. Returns true if the loop should return
|
||||
// (normal finish or error), false if tool execution should follow.
|
||||
func runStreamingRound(
|
||||
ctx context.Context,
|
||||
lcfg LoopConfig,
|
||||
sink LoopSink,
|
||||
result *LoopResult,
|
||||
iterContent, iterReasoning *string,
|
||||
toolCalls *[]providers.ToolCall,
|
||||
iterInput, iterOutput, iterCacheCreate, iterCacheRead *int,
|
||||
) bool {
|
||||
callStart := time.Now()
|
||||
ch, err := lcfg.Provider.StreamCompletion(ctx, lcfg.Cfg, *lcfg.Req)
|
||||
if err != nil {
|
||||
recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, err)
|
||||
result.Error = err
|
||||
sink.OnError(err)
|
||||
return true
|
||||
}
|
||||
|
||||
for event := range ch {
|
||||
// Apply provider-specific post-processing hooks (v0.22.1)
|
||||
if hooks := providers.GetHooks(lcfg.ProviderType); hooks != nil {
|
||||
hooks.PostStreamEvent(lcfg.Cfg, &event)
|
||||
}
|
||||
|
||||
if event.Error != nil {
|
||||
recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, event.Error)
|
||||
result.Error = event.Error
|
||||
sink.OnError(event.Error)
|
||||
return true
|
||||
}
|
||||
|
||||
// Reasoning deltas
|
||||
if event.Reasoning != "" {
|
||||
*iterReasoning += event.Reasoning
|
||||
sink.OnReasoning(event.Reasoning)
|
||||
}
|
||||
|
||||
// Content deltas
|
||||
if event.Delta != "" {
|
||||
*iterContent += event.Delta
|
||||
sink.OnDelta(event.Delta)
|
||||
}
|
||||
|
||||
if event.Done {
|
||||
recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, nil)
|
||||
|
||||
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
|
||||
*toolCalls = event.ToolCalls
|
||||
result.InputTokens += event.InputTokens
|
||||
result.OutputTokens += event.OutputTokens
|
||||
result.CacheCreationTokens += event.CacheCreationTokens
|
||||
result.CacheReadTokens += event.CacheReadTokens
|
||||
return false // continue to tool execution
|
||||
}
|
||||
|
||||
// Normal completion
|
||||
finishReason := event.FinishReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
if *iterReasoning != "" {
|
||||
result.Content += "<think>" + *iterReasoning + "</think>"
|
||||
}
|
||||
result.Content += *iterContent
|
||||
result.InputTokens += event.InputTokens
|
||||
result.OutputTokens += event.OutputTokens
|
||||
result.CacheCreationTokens += event.CacheCreationTokens
|
||||
result.CacheReadTokens += event.CacheReadTokens
|
||||
|
||||
sink.OnFinish(finishReason)
|
||||
sink.OnDone()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Channel closed without Done event — shouldn't happen
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Sync Round ─────────────────────────────────
|
||||
|
||||
// runSyncRound executes one provider ChatCompletion call. Returns true
|
||||
// if the loop should return, false if tool execution should follow.
|
||||
func runSyncRound(
|
||||
ctx context.Context,
|
||||
lcfg LoopConfig,
|
||||
sink LoopSink,
|
||||
result *LoopResult,
|
||||
iterContent, iterReasoning *string,
|
||||
toolCalls *[]providers.ToolCall,
|
||||
iterInput, iterOutput, iterCacheCreate, iterCacheRead *int,
|
||||
) bool {
|
||||
callStart := time.Now()
|
||||
resp, err := lcfg.Provider.ChatCompletion(ctx, lcfg.Cfg, *lcfg.Req)
|
||||
recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, err)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
sink.OnError(err)
|
||||
return true
|
||||
}
|
||||
|
||||
*iterContent = resp.Content
|
||||
*iterInput = resp.InputTokens
|
||||
*iterOutput = resp.OutputTokens
|
||||
*iterCacheCreate = resp.CacheCreationTokens
|
||||
*iterCacheRead = resp.CacheReadTokens
|
||||
|
||||
result.InputTokens += resp.InputTokens
|
||||
result.OutputTokens += resp.OutputTokens
|
||||
result.CacheCreationTokens += resp.CacheCreationTokens
|
||||
result.CacheReadTokens += resp.CacheReadTokens
|
||||
|
||||
if resp.FinishReason == "tool_calls" && len(resp.ToolCalls) > 0 {
|
||||
*toolCalls = resp.ToolCalls
|
||||
return false // continue to tool execution
|
||||
}
|
||||
|
||||
// Normal completion — deliver full content as single delta
|
||||
result.Content += resp.Content
|
||||
sink.OnDelta(resp.Content)
|
||||
finishReason := resp.FinishReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
sink.OnFinish(finishReason)
|
||||
sink.OnDone()
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Tool Execution ─────────────────────────────
|
||||
|
||||
// executeToolCall dispatches a single tool call: server-side first,
|
||||
// browser bridge fallback, unknown tool error as last resort.
|
||||
func executeToolCall(ctx context.Context, lcfg LoopConfig, call tools.ToolCall) tools.ToolResult {
|
||||
toolStart := time.Now()
|
||||
|
||||
// Server-side tool
|
||||
if tools.Get(call.Name) != nil {
|
||||
log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID)
|
||||
result := tools.ExecuteCall(ctx, lcfg.ExecCtx, call)
|
||||
if lcfg.Health != nil {
|
||||
toolLatency := int(time.Since(toolStart).Milliseconds())
|
||||
if result.IsError {
|
||||
lcfg.Health.RecordToolError(call.Name, toolLatency, result.Content)
|
||||
} else {
|
||||
lcfg.Health.RecordToolSuccess(call.Name, toolLatency)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Browser bridge (only available when hub is connected)
|
||||
if lcfg.Hub != nil && lcfg.Hub.IsConnected(lcfg.ExecCtx.UserID) {
|
||||
log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID)
|
||||
return executeBrowserTool(lcfg.Hub, lcfg.ExecCtx.UserID, call)
|
||||
}
|
||||
|
||||
// Unknown tool
|
||||
log.Printf("⚠️ Unknown tool: %s (call %s)", call.Name, call.ID)
|
||||
return tools.ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: `{"error":"unknown tool or browser not connected"}`,
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// emitWorkspaceEvent sends workspace.file.changed when a write tool succeeds.
|
||||
func emitWorkspaceEvent(lcfg LoopConfig, call tools.ToolCall, result tools.ToolResult) {
|
||||
if lcfg.Hub == nil || result.IsError || lcfg.ExecCtx.WorkspaceID == "" {
|
||||
return
|
||||
}
|
||||
if call.Name != "workspace_write" && call.Name != "workspace_patch" {
|
||||
return
|
||||
}
|
||||
var toolArgs struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" {
|
||||
lcfg.Hub.SendToUser(lcfg.ExecCtx.UserID, events.Event{
|
||||
Label: "workspace.file.changed",
|
||||
Payload: events.MustJSON(map[string]string{
|
||||
"workspace_id": lcfg.ExecCtx.WorkspaceID,
|
||||
"path": toolArgs.Path,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sink Implementations ───────────────────────
|
||||
|
||||
// sseSink writes SSE events to a gin.Context writer for interactive streaming.
|
||||
type sseSink struct {
|
||||
w http.ResponseWriter
|
||||
flush func()
|
||||
model string
|
||||
}
|
||||
|
||||
func newSSESink(c *gin.Context, model string) *sseSink {
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
return &sseSink{
|
||||
w: c.Writer,
|
||||
model: model,
|
||||
flush: func() {
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sseSink) sendData(data string) {
|
||||
fmt.Fprintf(s.w, "data: %s\n\n", data)
|
||||
s.flush()
|
||||
}
|
||||
|
||||
func (s *sseSink) sendEvent(event, data string) {
|
||||
fmt.Fprintf(s.w, "event: %s\ndata: %s\n\n", event, data)
|
||||
s.flush()
|
||||
}
|
||||
|
||||
func (s *sseSink) OnDelta(delta string) {
|
||||
s.sendData(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`,
|
||||
delta, s.model))
|
||||
}
|
||||
|
||||
func (s *sseSink) OnReasoning(delta string) {
|
||||
s.sendData(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q}`,
|
||||
delta, s.model))
|
||||
}
|
||||
|
||||
func (s *sseSink) OnError(err error) {
|
||||
s.sendData(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
|
||||
}
|
||||
|
||||
func (s *sseSink) OnToolUse(calls []providers.ToolCall) {
|
||||
toolCallsJSON, _ := json.Marshal(calls)
|
||||
s.sendEvent("tool_use", string(toolCallsJSON))
|
||||
}
|
||||
|
||||
func (s *sseSink) OnToolResult(result map[string]interface{}) {
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
s.sendEvent("tool_result", string(resultJSON))
|
||||
}
|
||||
|
||||
func (s *sseSink) OnFinish(reason string) {
|
||||
s.sendData(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
|
||||
reason, s.model))
|
||||
}
|
||||
|
||||
func (s *sseSink) OnDone() {
|
||||
s.sendData("[DONE]")
|
||||
}
|
||||
|
||||
func (s *sseSink) OnMaxRounds() {
|
||||
log.Printf("⚠️ Tool loop hit max iterations for model %s", s.model)
|
||||
s.sendData(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`,
|
||||
escapeJSON("[Tool execution limit reached]"), s.model))
|
||||
}
|
||||
|
||||
// sseModelSink extends sseSink with model_display attribution for
|
||||
// multi-model streaming. Does NOT send [DONE] — the multiModelStream
|
||||
// caller sends it after all models finish.
|
||||
type sseModelSink struct {
|
||||
sseSink
|
||||
displayName string
|
||||
}
|
||||
|
||||
func newSSEModelSink(c *gin.Context, model, displayName string) *sseModelSink {
|
||||
base := newSSESink(c, model)
|
||||
return &sseModelSink{
|
||||
sseSink: *base,
|
||||
displayName: displayName,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sseModelSink) OnDelta(delta string) {
|
||||
s.sendData(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`,
|
||||
delta, s.model, s.displayName))
|
||||
}
|
||||
|
||||
func (s *sseModelSink) OnReasoning(delta string) {
|
||||
s.sendData(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`,
|
||||
delta, s.model, s.displayName))
|
||||
}
|
||||
|
||||
func (s *sseModelSink) OnFinish(reason string) {
|
||||
s.sendData(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q,"model_display":%q}`,
|
||||
reason, s.model, s.displayName))
|
||||
}
|
||||
|
||||
// OnDone is a no-op for multi-model — caller sends [DONE] after all models.
|
||||
func (s *sseModelSink) OnDone() {}
|
||||
|
||||
func (s *sseModelSink) OnMaxRounds() {
|
||||
log.Printf("⚠️ Multi-model tool loop hit max iterations for model %s", s.displayName)
|
||||
s.sendData(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q,"model_display":%q}`,
|
||||
escapeJSON("[Tool execution limit reached]"), s.model, s.displayName))
|
||||
}
|
||||
|
||||
// accumSink accumulates silently — used by syncCompletion where the
|
||||
// caller formats the HTTP response after the loop returns.
|
||||
type accumSink struct{}
|
||||
|
||||
func (accumSink) OnDelta(string) {}
|
||||
func (accumSink) OnReasoning(string) {}
|
||||
func (accumSink) OnError(error) {}
|
||||
func (accumSink) OnToolUse([]providers.ToolCall) {}
|
||||
func (accumSink) OnToolResult(map[string]interface{}) {}
|
||||
func (accumSink) OnFinish(string) {}
|
||||
func (accumSink) OnDone() {}
|
||||
func (accumSink) OnMaxRounds() {}
|
||||
|
||||
// HeadlessSink is used by the task scheduler — accumulates and logs.
|
||||
// No client connection, no SSE output.
|
||||
type HeadlessSink struct {
|
||||
taskID string
|
||||
}
|
||||
|
||||
func NewHeadlessSink(taskID string) *HeadlessSink {
|
||||
return &HeadlessSink{taskID: taskID}
|
||||
}
|
||||
|
||||
func (s *HeadlessSink) OnDelta(string) {}
|
||||
func (s *HeadlessSink) OnReasoning(string) {}
|
||||
|
||||
func (s *HeadlessSink) OnError(err error) {
|
||||
log.Printf("[task:%s] Error: %v", s.taskID, err)
|
||||
}
|
||||
|
||||
func (s *HeadlessSink) OnToolUse(calls []providers.ToolCall) {
|
||||
names := make([]string, len(calls))
|
||||
for i, tc := range calls {
|
||||
names[i] = tc.Function.Name
|
||||
}
|
||||
log.Printf("[task:%s] Tool calls: %v", s.taskID, names)
|
||||
}
|
||||
|
||||
func (s *HeadlessSink) OnToolResult(result map[string]interface{}) {
|
||||
name, _ := result["name"].(string)
|
||||
isErr, _ := result["is_error"].(bool)
|
||||
if isErr {
|
||||
log.Printf("[task:%s] Tool %s failed: %v", s.taskID, name, result["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HeadlessSink) OnFinish(reason string) {
|
||||
log.Printf("[task:%s] Finished: %s", s.taskID, reason)
|
||||
}
|
||||
|
||||
func (s *HeadlessSink) OnDone() {}
|
||||
|
||||
func (s *HeadlessSink) OnMaxRounds() {
|
||||
log.Printf("[task:%s] Hit max tool iterations", s.taskID)
|
||||
}
|
||||
@@ -216,12 +216,7 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
// v0.27.1: Task scheduler — polls for due tasks every 30s
|
||||
if stores.Tasks != nil {
|
||||
taskSched := scheduler.New(stores)
|
||||
go taskSched.Run()
|
||||
log.Println(" ⏰ Task scheduler started")
|
||||
}
|
||||
// v0.27.2: Task scheduler startup deferred to after hub/notification init — see below.
|
||||
|
||||
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
||||
handlers.BootstrapAdmin(cfg, stores)
|
||||
@@ -407,6 +402,14 @@ func main() {
|
||||
defer notifSvc.StopCleanup()
|
||||
}
|
||||
|
||||
// v0.27.2: Task scheduler with executor — needs hub + notification service
|
||||
if stores.Tasks != nil {
|
||||
exec := scheduler.NewExecutor(stores, keyResolver, hub, healthAccum)
|
||||
taskSched := scheduler.New(stores, exec)
|
||||
go taskSched.Run()
|
||||
log.Println(" ⏰ Task scheduler started (with executor)")
|
||||
}
|
||||
|
||||
// Health check (k8s probes hit this directly)
|
||||
base.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
@@ -604,15 +607,16 @@ func main() {
|
||||
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
|
||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||
|
||||
// Tasks (v0.27.1)
|
||||
// Tasks (v0.27.1, permissions v0.27.2)
|
||||
taskH := handlers.NewTaskHandler(stores)
|
||||
protected.GET("/tasks", taskH.ListMine)
|
||||
protected.POST("/tasks", taskH.Create)
|
||||
protected.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Create)
|
||||
protected.GET("/tasks/:id", taskH.Get)
|
||||
protected.PUT("/tasks/:id", taskH.Update)
|
||||
protected.DELETE("/tasks/:id", taskH.Delete)
|
||||
protected.PUT("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Update)
|
||||
protected.DELETE("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Delete)
|
||||
protected.GET("/tasks/:id/runs", taskH.ListRuns)
|
||||
protected.POST("/tasks/:id/run", taskH.RunNow)
|
||||
protected.POST("/tasks/:id/run", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.RunNow)
|
||||
protected.POST("/tasks/:id/kill", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.KillRun)
|
||||
|
||||
// Channel models (v0.20.0 — multi-model @mention routing)
|
||||
chModelH := handlers.NewChannelModelHandler(stores)
|
||||
@@ -1093,9 +1097,12 @@ func main() {
|
||||
admin.PUT("/surfaces/:id/disable", surfaceAdm.DisableSurface)
|
||||
admin.DELETE("/surfaces/:id", surfaceAdm.DeleteSurface)
|
||||
|
||||
// Task management — admin (v0.27.1)
|
||||
// Task management — admin (v0.27.1, extended v0.27.2)
|
||||
taskAdm := handlers.NewTaskHandler(stores)
|
||||
admin.GET("/tasks", taskAdm.ListAll)
|
||||
admin.POST("/tasks/:id/run", taskAdm.RunNow)
|
||||
admin.POST("/tasks/:id/kill", taskAdm.KillRun)
|
||||
admin.DELETE("/tasks/:id", taskAdm.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,7 @@
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-scaffold.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-api.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-admin.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-admin.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
49
server/scheduler/cron.go
Normal file
49
server/scheduler/cron.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// cronParser is a shared parser instance. Standard 5-field cron with
|
||||
// optional descriptors (@hourly, @daily, @weekly, @monthly, etc.).
|
||||
var cronParser = cron.NewParser(
|
||||
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
|
||||
)
|
||||
|
||||
// NextRunFromSchedule computes the next run time from a cron expression
|
||||
// and timezone. Returns nil for "once" schedules (one-shot tasks).
|
||||
//
|
||||
// Replaces the v0.27.1 hand-rolled parseDailyCron with full 5-field
|
||||
// cron support via robfig/cron/v3.
|
||||
func NextRunFromSchedule(schedule, timezone string) *time.Time {
|
||||
if schedule == "once" {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if tz, err := time.LoadLocation(timezone); err == nil {
|
||||
now = now.In(tz)
|
||||
}
|
||||
|
||||
sched, err := cronParser.Parse(schedule)
|
||||
if err != nil {
|
||||
// Unparseable — log at call site, caller decides fallback
|
||||
return nil
|
||||
}
|
||||
|
||||
next := sched.Next(now).UTC()
|
||||
return &next
|
||||
}
|
||||
|
||||
// ValidateCron checks whether a cron expression is valid.
|
||||
// Returns nil for valid expressions, error describing the problem otherwise.
|
||||
// "once" is always valid (one-shot schedule).
|
||||
func ValidateCron(schedule string) error {
|
||||
if schedule == "once" {
|
||||
return nil
|
||||
}
|
||||
_, err := cronParser.Parse(schedule)
|
||||
return err
|
||||
}
|
||||
271
server/scheduler/executor.go
Normal file
271
server/scheduler/executor.go
Normal file
@@ -0,0 +1,271 @@
|
||||
// Package scheduler — executor.go
|
||||
//
|
||||
// v0.27.2: Headless task execution via coreToolLoop.
|
||||
//
|
||||
// The Executor bridges the task scheduler with the completion pipeline.
|
||||
// It resolves providers, builds tool definitions, runs the core tool loop
|
||||
// with budget enforcement, persists results, and sends notifications.
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/handlers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/notifications"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
|
||||
// Executor runs task completions headlessly (no HTTP client).
|
||||
type Executor struct {
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
hub *events.Hub
|
||||
health handlers.HealthRecorder
|
||||
}
|
||||
|
||||
// NewExecutor creates a task executor. All fields are optional except stores.
|
||||
func NewExecutor(stores store.Stores, vault *crypto.KeyResolver, hub *events.Hub, health handlers.HealthRecorder) *Executor {
|
||||
return &Executor{
|
||||
stores: stores,
|
||||
vault: vault,
|
||||
hub: hub,
|
||||
health: health,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute runs a single task to completion.
|
||||
// Called from the scheduler's execute() goroutine.
|
||||
func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) {
|
||||
startTime := time.Now()
|
||||
|
||||
// ── 1. Resolve provider ────────────────────
|
||||
providerConfigID := ""
|
||||
if task.ProviderConfigID != nil {
|
||||
providerConfigID = *task.ProviderConfigID
|
||||
}
|
||||
res, err := handlers.ResolveProviderConfig(e.vault, task.OwnerID, channelID, providerConfigID, task.ModelID)
|
||||
if err != nil {
|
||||
e.failRun(ctx, task, run, "provider resolution failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := providers.Get(res.ProviderID)
|
||||
if err != nil {
|
||||
e.failRun(ctx, task, run, "provider unavailable: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// ── 2. Build messages ──────────────────────
|
||||
messages := make([]providers.Message, 0, 3)
|
||||
|
||||
// System prompt: task-level > persona > empty
|
||||
systemPrompt := task.SystemPrompt
|
||||
if systemPrompt == "" && task.PersonaID != nil && e.stores.Personas != nil {
|
||||
if persona, err := e.stores.Personas.GetByID(ctx, *task.PersonaID); err == nil {
|
||||
systemPrompt = persona.SystemPrompt
|
||||
}
|
||||
}
|
||||
if systemPrompt != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: systemPrompt,
|
||||
})
|
||||
}
|
||||
|
||||
// User prompt
|
||||
if task.UserPrompt != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "user",
|
||||
Content: task.UserPrompt,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 3. Build tool definitions ──────────────
|
||||
caps := capspkg.InferCapabilities(res.Model)
|
||||
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(res.Model, caps)
|
||||
|
||||
var toolDefs []providers.ToolDef
|
||||
if caps.ToolCalling {
|
||||
personaID := ""
|
||||
if task.PersonaID != nil {
|
||||
personaID = *task.PersonaID
|
||||
}
|
||||
tctx := tools.ToolContext{
|
||||
ChannelType: "service",
|
||||
PersonaID: personaID,
|
||||
}
|
||||
// No browser tools for headless execution
|
||||
toolDefs = handlers.BuildToolDefs(ctx, e.stores, task.OwnerID, false, nil, tctx, personaID)
|
||||
|
||||
// Apply task-level tool grants
|
||||
if len(task.ToolGrants) > 0 {
|
||||
var grants []string
|
||||
if json.Unmarshal(task.ToolGrants, &grants) == nil && len(grants) > 0 {
|
||||
toolDefs = handlers.FilterToolDefsByGrants(toolDefs, grants)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Build completion request ────────────
|
||||
req := providers.CompletionRequest{
|
||||
Model: res.Model,
|
||||
Messages: messages,
|
||||
Tools: toolDefs,
|
||||
}
|
||||
if task.MaxTokens > 0 {
|
||||
req.MaxTokens = task.MaxTokens
|
||||
} else if caps.MaxOutputTokens > 0 {
|
||||
req.MaxTokens = caps.MaxOutputTokens
|
||||
}
|
||||
|
||||
// Apply provider-specific request hooks
|
||||
if hooks := providers.GetHooks(res.ProviderID); hooks != nil {
|
||||
hooks.PreRequest(res.Config, &req)
|
||||
}
|
||||
|
||||
// ── 5. Execute via core tool loop ──────────
|
||||
personaID := ""
|
||||
if task.PersonaID != nil {
|
||||
personaID = *task.PersonaID
|
||||
}
|
||||
|
||||
sink := handlers.NewHeadlessSink(task.ID)
|
||||
result := handlers.CoreToolLoop(ctx, handlers.LoopConfig{
|
||||
Provider: provider,
|
||||
Cfg: res.Config,
|
||||
Req: &req,
|
||||
Model: res.Model,
|
||||
ProviderType: res.ProviderID,
|
||||
ExecCtx: tools.ExecutionContext{
|
||||
UserID: task.OwnerID,
|
||||
ChannelID: channelID,
|
||||
PersonaID: personaID,
|
||||
},
|
||||
Hub: e.hub,
|
||||
Health: e.health,
|
||||
ConfigID: res.ConfigID,
|
||||
Budget: handlers.LoopBudget{
|
||||
MaxRounds: 0, // use default
|
||||
MaxToolCalls: task.MaxToolCalls,
|
||||
MaxTokens: task.MaxTokens,
|
||||
},
|
||||
Streaming: false, // headless — use ChatCompletion
|
||||
}, sink)
|
||||
|
||||
// ── 6. Persist assistant response ──────────
|
||||
wallClock := int(time.Since(startTime).Seconds())
|
||||
|
||||
if result.Content != "" && e.stores.Messages != nil {
|
||||
_ = e.stores.Messages.Create(ctx, &models.Message{
|
||||
ChannelID: channelID,
|
||||
Role: "assistant",
|
||||
Content: result.Content,
|
||||
Model: res.Model,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 7. Determine terminal status ───────────
|
||||
status := "completed"
|
||||
errMsg := ""
|
||||
|
||||
if result.Error != nil {
|
||||
status = "failed"
|
||||
errMsg = result.Error.Error()
|
||||
} else if result.BudgetExceeded != "" {
|
||||
status = "budget_exceeded"
|
||||
errMsg = "budget exceeded: " + result.BudgetExceeded
|
||||
}
|
||||
|
||||
// ── 8. Update run record ───────────────────
|
||||
_ = e.stores.Tasks.UpdateRun(ctx, run.ID, status,
|
||||
result.InputTokens+result.OutputTokens,
|
||||
result.ToolCallCount,
|
||||
wallClock,
|
||||
errMsg)
|
||||
_ = e.stores.Tasks.IncrementRunCount(ctx, task.ID)
|
||||
|
||||
log.Printf("[executor] Task %s (%s) → %s (tokens=%d, tools=%d, wall=%ds)",
|
||||
task.ID, task.Name, status,
|
||||
result.InputTokens+result.OutputTokens,
|
||||
result.ToolCallCount, wallClock)
|
||||
|
||||
// ── 9. Owner notification ──────────────────
|
||||
e.notifyOwner(ctx, task, status, errMsg)
|
||||
}
|
||||
|
||||
// failRun marks a run as failed before completion was attempted.
|
||||
func (e *Executor) failRun(ctx context.Context, task models.Task, run *models.TaskRun, errMsg string) {
|
||||
log.Printf("[executor] Task %s (%s) pre-execution failure: %s", task.ID, task.Name, errMsg)
|
||||
_ = e.stores.Tasks.UpdateRun(ctx, run.ID, "failed", 0, 0, 0, errMsg)
|
||||
e.notifyOwner(ctx, task, "failed", errMsg)
|
||||
}
|
||||
|
||||
// notifyOwner sends a notification based on task outcome and preferences.
|
||||
func (e *Executor) notifyOwner(ctx context.Context, task models.Task, status, errMsg string) {
|
||||
notifSvc := notifications.Default()
|
||||
if notifSvc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch status {
|
||||
case "completed":
|
||||
if !task.NotifyOnComplete {
|
||||
return
|
||||
}
|
||||
_ = notifSvc.Notify(ctx, &models.Notification{
|
||||
UserID: task.OwnerID,
|
||||
Type: "task.completed",
|
||||
Title: "Task completed: " + task.Name,
|
||||
Body: "Scheduled task finished successfully.",
|
||||
ResourceType: "channel",
|
||||
ResourceID: stringVal(task.OutputChannelID),
|
||||
})
|
||||
|
||||
case "failed":
|
||||
if !task.NotifyOnFailure {
|
||||
return
|
||||
}
|
||||
body := "Scheduled task failed."
|
||||
if errMsg != "" {
|
||||
body = errMsg
|
||||
if len(body) > 200 {
|
||||
body = body[:200] + "…"
|
||||
}
|
||||
}
|
||||
_ = notifSvc.Notify(ctx, &models.Notification{
|
||||
UserID: task.OwnerID,
|
||||
Type: "task.failed",
|
||||
Title: "Task failed: " + task.Name,
|
||||
Body: body,
|
||||
ResourceType: "channel",
|
||||
ResourceID: stringVal(task.OutputChannelID),
|
||||
})
|
||||
|
||||
case "budget_exceeded":
|
||||
// Always notify on budget breach regardless of preference
|
||||
_ = notifSvc.Notify(ctx, &models.Notification{
|
||||
UserID: task.OwnerID,
|
||||
Type: "task.budget_exceeded",
|
||||
Title: "Task budget exceeded: " + task.Name,
|
||||
Body: errMsg,
|
||||
ResourceType: "channel",
|
||||
ResourceID: stringVal(task.OutputChannelID),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stringVal(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
// every 30 seconds, creates service channels, and dispatches execution.
|
||||
//
|
||||
// v0.27.1: Foundation — scheduler loop + service channel creation.
|
||||
// v0.27.2: Adds completion invocation, budget enforcement, and wall-clock timeout.
|
||||
// v0.27.2: Adds global config checks (enabled, max_concurrent), full cron
|
||||
// parsing via robfig/cron/v3, and completion invocation via executor.
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
@@ -12,20 +13,25 @@ import (
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
||||
)
|
||||
|
||||
// Scheduler polls for due tasks and dispatches execution.
|
||||
type Scheduler struct {
|
||||
stores store.Stores
|
||||
executor *Executor
|
||||
interval time.Duration
|
||||
stop chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
// New creates a task scheduler. Call Run() in a goroutine to start.
|
||||
func New(stores store.Stores) *Scheduler {
|
||||
// The executor is optional — if nil, tasks create channels and persist
|
||||
// prompts but do not invoke completions (v0.27.1 behavior).
|
||||
func New(stores store.Stores, executor *Executor) *Scheduler {
|
||||
return &Scheduler{
|
||||
stores: stores,
|
||||
executor: executor,
|
||||
interval: 30 * time.Second,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
@@ -67,7 +73,13 @@ func (s *Scheduler) poll() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
due, err := s.stores.Tasks.ListDue(ctx, 10)
|
||||
// v0.27.2: Check global config — tasks may be disabled at runtime.
|
||||
cfg := taskutil.LoadTaskConfig(ctx, s.stores.GlobalConfig)
|
||||
if !cfg.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
due, err := s.stores.Tasks.ListDue(ctx, cfg.MaxConcurrent)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] Failed to list due tasks: %v", err)
|
||||
return
|
||||
@@ -123,17 +135,16 @@ func (s *Scheduler) execute(parentCtx context.Context, task models.Task) {
|
||||
})
|
||||
}
|
||||
|
||||
// v0.27.2 TODO: Invoke completion pipeline with:
|
||||
// - task.SystemPrompt prepended to persona system prompt
|
||||
// - task.ModelID or provider resolution
|
||||
// - Budget enforcement (max_tokens, max_tool_calls, max_wall_clock)
|
||||
// - Tool grant filtering from task.ToolGrants
|
||||
//
|
||||
// For now, mark the run as completed (channel created + prompt persisted).
|
||||
_ = s.stores.Tasks.UpdateRun(ctx, run.ID, "completed", 0, 0, 0, "")
|
||||
_ = s.stores.Tasks.IncrementRunCount(ctx, task.ID)
|
||||
// v0.27.2: Invoke completion via executor
|
||||
if s.executor != nil && task.TaskType == "prompt" {
|
||||
s.executor.Execute(ctx, task, run, channelID)
|
||||
} else {
|
||||
// No executor or non-prompt task type — mark completed (channel + prompt persisted)
|
||||
_ = s.stores.Tasks.UpdateRun(ctx, run.ID, "completed", 0, 0, 0, "")
|
||||
_ = s.stores.Tasks.IncrementRunCount(ctx, task.ID)
|
||||
}
|
||||
|
||||
log.Printf("[scheduler] Task %s completed (channel %s)", task.ID, channelID)
|
||||
log.Printf("[scheduler] Task %s finished (channel %s)", task.ID, channelID)
|
||||
|
||||
s.advanceNextRun(ctx, task)
|
||||
}
|
||||
@@ -176,108 +187,14 @@ func (s *Scheduler) advanceNextRun(ctx context.Context, task models.Task) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cron schedule — compute next run.
|
||||
// v0.27.1: Uses a simple interval-based fallback.
|
||||
// v0.27.2: Full cron parsing with robfig/cron/v3.
|
||||
next := computeNextRun(task.Schedule, task.Timezone)
|
||||
// v0.27.2: Full cron parsing via robfig/cron/v3 (replaces hand-rolled parser).
|
||||
next := taskutil.NextRunFromSchedule(task.Schedule, task.Timezone)
|
||||
if next == nil {
|
||||
log.Printf("[scheduler] Failed to compute next run for task %s (schedule: %q) — deactivating", task.ID, task.Schedule)
|
||||
isActive := false
|
||||
_ = s.stores.Tasks.Update(ctx, task.ID, models.TaskPatch{IsActive: &isActive})
|
||||
_ = s.stores.Tasks.SetNextRun(ctx, task.ID, nil)
|
||||
return
|
||||
}
|
||||
_ = s.stores.Tasks.SetNextRun(ctx, task.ID, next)
|
||||
}
|
||||
|
||||
// computeNextRun parses a cron expression and returns the next execution time.
|
||||
// Supports common presets and basic 5-field cron.
|
||||
func computeNextRun(schedule, timezone string) *time.Time {
|
||||
now := time.Now()
|
||||
|
||||
// Load timezone
|
||||
if tz, err := time.LoadLocation(timezone); err == nil {
|
||||
now = now.In(tz)
|
||||
}
|
||||
|
||||
// Preset schedules (common cases without a full cron parser)
|
||||
var next time.Time
|
||||
switch schedule {
|
||||
case "once":
|
||||
return nil // Already handled
|
||||
case "0 * * * *": // Every hour
|
||||
next = now.Truncate(time.Hour).Add(time.Hour)
|
||||
case "*/5 * * * *": // Every 5 minutes
|
||||
next = now.Truncate(5 * time.Minute).Add(5 * time.Minute)
|
||||
case "*/15 * * * *": // Every 15 minutes
|
||||
next = now.Truncate(15 * time.Minute).Add(15 * time.Minute)
|
||||
case "*/30 * * * *": // Every 30 minutes
|
||||
next = now.Truncate(30 * time.Minute).Add(30 * time.Minute)
|
||||
default:
|
||||
// Fallback: try to parse minute and hour fields for daily/weekly cron
|
||||
next = parseDailyCron(schedule, now)
|
||||
}
|
||||
|
||||
utc := next.UTC()
|
||||
return &utc
|
||||
}
|
||||
|
||||
// parseDailyCron handles "M H * * *" and "M H * * D" patterns.
|
||||
// Returns now+1h as fallback for unparseable expressions.
|
||||
func parseDailyCron(expr string, now time.Time) time.Time {
|
||||
// Split into fields
|
||||
var fields []string
|
||||
field := ""
|
||||
for _, c := range expr {
|
||||
if c == ' ' || c == '\t' {
|
||||
if field != "" {
|
||||
fields = append(fields, field)
|
||||
field = ""
|
||||
}
|
||||
} else {
|
||||
field += string(c)
|
||||
}
|
||||
}
|
||||
if field != "" {
|
||||
fields = append(fields, field)
|
||||
}
|
||||
|
||||
if len(fields) < 5 {
|
||||
return now.Add(time.Hour) // Unparseable — retry in 1h
|
||||
}
|
||||
|
||||
minute := parseField(fields[0], 0)
|
||||
hour := parseField(fields[1], 0)
|
||||
|
||||
// Construct today's target time
|
||||
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
||||
if target.After(now) {
|
||||
return target
|
||||
}
|
||||
|
||||
// Check day-of-week field
|
||||
if fields[4] != "*" {
|
||||
dow := parseField(fields[4], -1)
|
||||
if dow >= 0 && dow <= 6 {
|
||||
// Advance to next matching day
|
||||
for i := 1; i <= 7; i++ {
|
||||
candidate := target.AddDate(0, 0, i)
|
||||
if int(candidate.Weekday()) == dow {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: next day at same time
|
||||
return target.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
// parseField parses a single cron field. Returns def for "*" or errors.
|
||||
func parseField(s string, def int) int {
|
||||
if s == "*" {
|
||||
return def
|
||||
}
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
} else {
|
||||
return def // step/range/list — not supported in minimal parser
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
110
server/scheduler/task_config.go
Normal file
110
server/scheduler/task_config.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// TaskConfig holds the runtime task configuration read from global_settings.
|
||||
// Keys: tasks.enabled, tasks.allow_personal, tasks.max_concurrent,
|
||||
// tasks.default_max_tokens, tasks.default_max_tool_calls,
|
||||
// tasks.default_max_wall_clock
|
||||
type TaskConfig struct {
|
||||
Enabled bool
|
||||
AllowPersonal bool
|
||||
MaxConcurrent int
|
||||
DefaultMaxTokens int
|
||||
DefaultMaxToolCalls int
|
||||
DefaultMaxWallClock int // seconds
|
||||
}
|
||||
|
||||
// DefaultTaskConfig returns sensible defaults when no global config is set.
|
||||
func DefaultTaskConfig() TaskConfig {
|
||||
return TaskConfig{
|
||||
Enabled: true,
|
||||
AllowPersonal: true,
|
||||
MaxConcurrent: 5,
|
||||
DefaultMaxTokens: 4096,
|
||||
DefaultMaxToolCalls: 10,
|
||||
DefaultMaxWallClock: 300,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadTaskConfig reads task configuration from global_settings.
|
||||
// Falls back to defaults for missing keys.
|
||||
func LoadTaskConfig(ctx context.Context, gc store.GlobalConfigStore) TaskConfig {
|
||||
cfg := DefaultTaskConfig()
|
||||
if gc == nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
raw, err := gc.Get(ctx, "tasks")
|
||||
if err != nil || raw == nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
if v, ok := boolVal(raw, "enabled"); ok {
|
||||
cfg.Enabled = v
|
||||
}
|
||||
if v, ok := boolVal(raw, "allow_personal"); ok {
|
||||
cfg.AllowPersonal = v
|
||||
}
|
||||
if v, ok := intVal(raw, "max_concurrent"); ok && v > 0 {
|
||||
cfg.MaxConcurrent = v
|
||||
}
|
||||
if v, ok := intVal(raw, "default_max_tokens"); ok && v > 0 {
|
||||
cfg.DefaultMaxTokens = v
|
||||
}
|
||||
if v, ok := intVal(raw, "default_max_tool_calls"); ok && v > 0 {
|
||||
cfg.DefaultMaxToolCalls = v
|
||||
}
|
||||
if v, ok := intVal(raw, "default_max_wall_clock"); ok && v > 0 {
|
||||
cfg.DefaultMaxWallClock = v
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ApplyDefaults fills zero-value budget fields on a task with the global defaults.
|
||||
func (tc TaskConfig) ApplyDefaults(t *models.Task) {
|
||||
if t.MaxTokens == 0 {
|
||||
t.MaxTokens = tc.DefaultMaxTokens
|
||||
}
|
||||
if t.MaxToolCalls == 0 {
|
||||
t.MaxToolCalls = tc.DefaultMaxToolCalls
|
||||
}
|
||||
if t.MaxWallClock == 0 {
|
||||
t.MaxWallClock = tc.DefaultMaxWallClock
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────
|
||||
|
||||
func boolVal(m models.JSONMap, key string) (bool, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
return b, ok
|
||||
}
|
||||
|
||||
func intVal(m models.JSONMap, key string) (int, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
// JSON numbers are float64 after Unmarshal
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
default:
|
||||
log.Printf("[task_config] unexpected type for %s: %T", key, v)
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
49
server/taskutil/cron.go
Normal file
49
server/taskutil/cron.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package taskutil
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// cronParser is a shared parser instance. Standard 5-field cron with
|
||||
// optional descriptors (@hourly, @daily, @weekly, @monthly, etc.).
|
||||
var cronParser = cron.NewParser(
|
||||
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
|
||||
)
|
||||
|
||||
// NextRunFromSchedule computes the next run time from a cron expression
|
||||
// and timezone. Returns nil for "once" schedules (one-shot tasks).
|
||||
//
|
||||
// Replaces the v0.27.1 hand-rolled parseDailyCron with full 5-field
|
||||
// cron support via robfig/cron/v3.
|
||||
func NextRunFromSchedule(schedule, timezone string) *time.Time {
|
||||
if schedule == "once" {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if tz, err := time.LoadLocation(timezone); err == nil {
|
||||
now = now.In(tz)
|
||||
}
|
||||
|
||||
sched, err := cronParser.Parse(schedule)
|
||||
if err != nil {
|
||||
// Unparseable — log at call site, caller decides fallback
|
||||
return nil
|
||||
}
|
||||
|
||||
next := sched.Next(now).UTC()
|
||||
return &next
|
||||
}
|
||||
|
||||
// ValidateCron checks whether a cron expression is valid.
|
||||
// Returns nil for valid expressions, error describing the problem otherwise.
|
||||
// "once" is always valid (one-shot schedule).
|
||||
func ValidateCron(schedule string) error {
|
||||
if schedule == "once" {
|
||||
return nil
|
||||
}
|
||||
_, err := cronParser.Parse(schedule)
|
||||
return err
|
||||
}
|
||||
110
server/taskutil/task_config.go
Normal file
110
server/taskutil/task_config.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package taskutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// TaskConfig holds the runtime task configuration read from global_settings.
|
||||
// Keys: tasks.enabled, tasks.allow_personal, tasks.max_concurrent,
|
||||
// tasks.default_max_tokens, tasks.default_max_tool_calls,
|
||||
// tasks.default_max_wall_clock
|
||||
type TaskConfig struct {
|
||||
Enabled bool
|
||||
AllowPersonal bool
|
||||
MaxConcurrent int
|
||||
DefaultMaxTokens int
|
||||
DefaultMaxToolCalls int
|
||||
DefaultMaxWallClock int // seconds
|
||||
}
|
||||
|
||||
// DefaultTaskConfig returns sensible defaults when no global config is set.
|
||||
func DefaultTaskConfig() TaskConfig {
|
||||
return TaskConfig{
|
||||
Enabled: true,
|
||||
AllowPersonal: true,
|
||||
MaxConcurrent: 5,
|
||||
DefaultMaxTokens: 4096,
|
||||
DefaultMaxToolCalls: 10,
|
||||
DefaultMaxWallClock: 300,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadTaskConfig reads task configuration from global_settings.
|
||||
// Falls back to defaults for missing keys.
|
||||
func LoadTaskConfig(ctx context.Context, gc store.GlobalConfigStore) TaskConfig {
|
||||
cfg := DefaultTaskConfig()
|
||||
if gc == nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
raw, err := gc.Get(ctx, "tasks")
|
||||
if err != nil || raw == nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
if v, ok := boolVal(raw, "enabled"); ok {
|
||||
cfg.Enabled = v
|
||||
}
|
||||
if v, ok := boolVal(raw, "allow_personal"); ok {
|
||||
cfg.AllowPersonal = v
|
||||
}
|
||||
if v, ok := intVal(raw, "max_concurrent"); ok && v > 0 {
|
||||
cfg.MaxConcurrent = v
|
||||
}
|
||||
if v, ok := intVal(raw, "default_max_tokens"); ok && v > 0 {
|
||||
cfg.DefaultMaxTokens = v
|
||||
}
|
||||
if v, ok := intVal(raw, "default_max_tool_calls"); ok && v > 0 {
|
||||
cfg.DefaultMaxToolCalls = v
|
||||
}
|
||||
if v, ok := intVal(raw, "default_max_wall_clock"); ok && v > 0 {
|
||||
cfg.DefaultMaxWallClock = v
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ApplyDefaults fills zero-value budget fields on a task with the global defaults.
|
||||
func (tc TaskConfig) ApplyDefaults(t *models.Task) {
|
||||
if t.MaxTokens == 0 {
|
||||
t.MaxTokens = tc.DefaultMaxTokens
|
||||
}
|
||||
if t.MaxToolCalls == 0 {
|
||||
t.MaxToolCalls = tc.DefaultMaxToolCalls
|
||||
}
|
||||
if t.MaxWallClock == 0 {
|
||||
t.MaxWallClock = tc.DefaultMaxWallClock
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────
|
||||
|
||||
func boolVal(m models.JSONMap, key string) (bool, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
return b, ok
|
||||
}
|
||||
|
||||
func intVal(m models.JSONMap, key string) (int, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
// JSON numbers are float64 after Unmarshal
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
default:
|
||||
log.Printf("[task_config] unexpected type for %s: %T", key, v)
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
251
src/js/task-admin.js
Normal file
251
src/js/task-admin.js
Normal file
@@ -0,0 +1,251 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Task Admin Panel
|
||||
// ==========================================
|
||||
// Admin section for managing scheduled tasks, run history, and global
|
||||
// task configuration. Registered as ADMIN_LOADERS.tasks in ui-admin.js.
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var SCAFFOLD_HTML =
|
||||
'<div id="taskAdminTabs" style="display:flex;gap:8px;margin-bottom:16px">' +
|
||||
'<button class="btn-small btn-primary" id="taskTabList">Tasks</button>' +
|
||||
'<button class="btn-small" id="taskTabConfig">Configuration</button>' +
|
||||
'</div>' +
|
||||
'<div id="taskListPane">' +
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' +
|
||||
'<h4 style="margin:0">Scheduled Tasks</h4>' +
|
||||
'<span id="taskCount" class="badge">0</span>' +
|
||||
'</div>' +
|
||||
'<div id="taskAdminList" class="admin-list"></div>' +
|
||||
'</div>' +
|
||||
'<div id="taskConfigPane" style="display:none">' +
|
||||
'<h4 style="margin:0 0 12px">Task Configuration</h4>' +
|
||||
'<div class="settings-section">' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="taskCfgEnabled"><span class="toggle-track"></span><span>Tasks Enabled</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="taskCfgPersonal"><span class="toggle-track"></span><span>Allow Personal Tasks</span></label>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:12px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Max Concurrent</label>' +
|
||||
'<input type="number" id="taskCfgMaxConcurrent" min="1" max="50" style="width:100%"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Default Max Tokens</label>' +
|
||||
'<input type="number" id="taskCfgMaxTokens" min="1" style="width:100%"></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Default Max Tool Calls</label>' +
|
||||
'<input type="number" id="taskCfgMaxToolCalls" min="1" style="width:100%"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Default Max Wall Clock (seconds)</label>' +
|
||||
'<input type="number" id="taskCfgMaxWallClock" min="10" style="width:100%"></div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-small btn-primary" id="taskCfgSaveBtn" style="margin-top:12px">Save Configuration</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="taskRunPane" style="display:none">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||
'<button class="btn-small" id="taskRunBackBtn">← Back</button>' +
|
||||
'<h4 id="taskRunTitle" style="margin:0;flex:1"></h4>' +
|
||||
'<button class="btn-small btn-danger" id="taskKillBtn">Kill Active Run</button>' +
|
||||
'</div>' +
|
||||
'<div id="taskRunList" class="admin-list"></div>' +
|
||||
'</div>';
|
||||
|
||||
function showTab(tab) {
|
||||
var el = function(id) { return document.getElementById(id); };
|
||||
el('taskListPane').style.display = tab === 'list' ? '' : 'none';
|
||||
el('taskConfigPane').style.display = tab === 'config' ? '' : 'none';
|
||||
el('taskRunPane').style.display = tab === 'runs' ? '' : 'none';
|
||||
el('taskTabList').className = 'btn-small' + (tab === 'list' ? ' btn-primary' : '');
|
||||
el('taskTabConfig').className = 'btn-small' + (tab === 'config' ? ' btn-primary' : '');
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
var colors = {
|
||||
running: 'badge-info', completed: 'badge-success', failed: 'badge-danger',
|
||||
budget_exceeded: 'badge-warning', cancelled: 'badge-muted'
|
||||
};
|
||||
return '<span class="badge ' + (colors[status] || '') + '">' + status + '</span>';
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
var resp = await App.api.get('/api/v1/admin/tasks');
|
||||
var tasks = resp.data || [];
|
||||
var list = document.getElementById('taskAdminList');
|
||||
document.getElementById('taskCount').textContent = tasks.length;
|
||||
|
||||
if (!tasks.length) {
|
||||
list.innerHTML = '<div class="admin-empty">No scheduled tasks</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = tasks.map(function(t) {
|
||||
var active = t.is_active
|
||||
? '<span class="badge badge-success">active</span>'
|
||||
: '<span class="badge badge-muted">paused</span>';
|
||||
var scope = '<span class="badge">' + t.scope + '</span>';
|
||||
var type = '<span class="badge">' + t.task_type + '</span>';
|
||||
var schedule = t.schedule === 'once' ? 'One-shot' : t.schedule;
|
||||
var nextRun = t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014';
|
||||
var lastRun = t.last_run_at ? new Date(t.last_run_at).toLocaleString() : 'never';
|
||||
|
||||
return '<div class="admin-row" style="display:flex;align-items:center;gap:12px;padding:10px 12px">' +
|
||||
'<div style="flex:2">' +
|
||||
'<div style="font-weight:600;font-size:13px">' + escapeHtml(t.name) + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">' +
|
||||
escapeHtml(schedule) + ' \u00b7 ' + t.run_count + ' runs \u00b7 last: ' + lastRun +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:6px;align-items:center">' + type + scope + active + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-secondary);min-width:140px;text-align:right">next: ' + nextRun + '</div>' +
|
||||
'<div style="display:flex;gap:4px">' +
|
||||
'<button class="btn-small task-run-btn" data-id="' + t.id + '" title="Run Now">\u25b6</button>' +
|
||||
'<button class="btn-small task-history-btn" data-id="' + t.id + '" data-name="' + escapeHtml(t.name) + '" title="History">\ud83d\udccb</button>' +
|
||||
'<button class="btn-small btn-danger task-delete-btn" data-id="' + t.id + '" title="Delete">\u2715</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('.task-run-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) { e.stopPropagation(); triggerRun(btn.dataset.id); });
|
||||
});
|
||||
list.querySelectorAll('.task-history-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) { e.stopPropagation(); showRuns(btn.dataset.id, btn.dataset.name); });
|
||||
});
|
||||
list.querySelectorAll('.task-delete-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) { e.stopPropagation(); deleteTask(btn.dataset.id); });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[task-admin] Failed to load tasks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerRun(taskId) {
|
||||
try {
|
||||
await App.api.post('/api/v1/admin/tasks/' + taskId + '/run', {});
|
||||
App.showToast('Task scheduled for immediate execution', 'success');
|
||||
} catch (err) {
|
||||
App.showToast(err.message || 'Failed to trigger run', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(taskId) {
|
||||
if (!confirm('Delete this task and all run history?')) return;
|
||||
try {
|
||||
await App.api.delete('/api/v1/admin/tasks/' + taskId);
|
||||
App.showToast('Task deleted', 'success');
|
||||
loadTasks();
|
||||
} catch (err) {
|
||||
App.showToast(err.message || 'Failed to delete task', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
var _currentTaskId = null;
|
||||
|
||||
async function showRuns(taskId, taskName) {
|
||||
_currentTaskId = taskId;
|
||||
document.getElementById('taskRunTitle').textContent = 'Run History: ' + taskName;
|
||||
showTab('runs');
|
||||
try {
|
||||
var resp = await App.api.get('/api/v1/tasks/' + taskId + '/runs');
|
||||
var runs = resp.data || [];
|
||||
var list = document.getElementById('taskRunList');
|
||||
|
||||
if (!runs.length) {
|
||||
list.innerHTML = '<div class="admin-empty">No runs yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = runs.map(function(r) {
|
||||
var started = new Date(r.started_at).toLocaleString();
|
||||
var completed = r.completed_at ? new Date(r.completed_at).toLocaleString() : '\u2014';
|
||||
var duration = r.wall_clock ? r.wall_clock + 's' : '\u2014';
|
||||
var errLine = r.error
|
||||
? '<div style="font-size:11px;color:var(--danger);margin-top:2px">' + escapeHtml(r.error) + '</div>'
|
||||
: '';
|
||||
|
||||
return '<div class="admin-row" style="padding:10px 12px">' +
|
||||
'<div style="display:flex;align-items:center;gap:12px">' +
|
||||
'<div style="flex:1">' +
|
||||
'<div style="font-size:12px">' + started + ' \u2192 ' + completed + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">' +
|
||||
'tokens: ' + r.tokens_used + ' \u00b7 tools: ' + r.tool_calls + ' \u00b7 wall: ' + duration +
|
||||
'</div>' +
|
||||
errLine +
|
||||
'</div>' +
|
||||
statusBadge(r.status) +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('[task-admin] Failed to load runs:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function killRun() {
|
||||
if (!_currentTaskId) return;
|
||||
try {
|
||||
await App.api.post('/api/v1/admin/tasks/' + _currentTaskId + '/kill', {});
|
||||
App.showToast('Active run cancelled', 'success');
|
||||
showRuns(_currentTaskId, document.getElementById('taskRunTitle').textContent.replace('Run History: ', ''));
|
||||
} catch (err) {
|
||||
App.showToast(err.message || 'Failed to kill run', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
var resp = await App.api.get('/api/v1/admin/settings/tasks');
|
||||
var cfg = resp || {};
|
||||
document.getElementById('taskCfgEnabled').checked = cfg.enabled !== false;
|
||||
document.getElementById('taskCfgPersonal').checked = cfg.allow_personal !== false;
|
||||
document.getElementById('taskCfgMaxConcurrent').value = cfg.max_concurrent || 5;
|
||||
document.getElementById('taskCfgMaxTokens').value = cfg.default_max_tokens || 4096;
|
||||
document.getElementById('taskCfgMaxToolCalls').value = cfg.default_max_tool_calls || 10;
|
||||
document.getElementById('taskCfgMaxWallClock').value = cfg.default_max_wall_clock || 300;
|
||||
} catch (_) {
|
||||
document.getElementById('taskCfgEnabled').checked = true;
|
||||
document.getElementById('taskCfgPersonal').checked = true;
|
||||
document.getElementById('taskCfgMaxConcurrent').value = 5;
|
||||
document.getElementById('taskCfgMaxTokens').value = 4096;
|
||||
document.getElementById('taskCfgMaxToolCalls').value = 10;
|
||||
document.getElementById('taskCfgMaxWallClock').value = 300;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
try {
|
||||
var payload = {
|
||||
enabled: document.getElementById('taskCfgEnabled').checked,
|
||||
allow_personal: document.getElementById('taskCfgPersonal').checked,
|
||||
max_concurrent: parseInt(document.getElementById('taskCfgMaxConcurrent').value) || 5,
|
||||
default_max_tokens: parseInt(document.getElementById('taskCfgMaxTokens').value) || 4096,
|
||||
default_max_tool_calls: parseInt(document.getElementById('taskCfgMaxToolCalls').value) || 10,
|
||||
default_max_wall_clock: parseInt(document.getElementById('taskCfgMaxWallClock').value) || 300
|
||||
};
|
||||
await App.api.put('/api/v1/admin/settings/tasks', payload);
|
||||
App.showToast('Task configuration saved', 'success');
|
||||
} catch (err) {
|
||||
App.showToast(err.message || 'Failed to save config', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
window._loadAdminTasks = async function() {
|
||||
var container = document.getElementById('adminDynamic');
|
||||
if (!container) return;
|
||||
container.innerHTML = SCAFFOLD_HTML;
|
||||
|
||||
document.getElementById('taskTabList').addEventListener('click', function() { showTab('list'); loadTasks(); });
|
||||
document.getElementById('taskTabConfig').addEventListener('click', function() { showTab('config'); loadConfig(); });
|
||||
document.getElementById('taskRunBackBtn').addEventListener('click', function() { showTab('list'); });
|
||||
document.getElementById('taskKillBtn').addEventListener('click', killRun);
|
||||
document.getElementById('taskCfgSaveBtn').addEventListener('click', saveConfig);
|
||||
|
||||
await loadTasks();
|
||||
};
|
||||
})();
|
||||
@@ -8,7 +8,7 @@
|
||||
const ADMIN_SECTIONS = {
|
||||
people: ['users', 'teams', 'groups'],
|
||||
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
|
||||
workflows: ['workflows'],
|
||||
workflows: ['workflows', 'tasks'],
|
||||
routing: ['health', 'routing', 'capabilities'],
|
||||
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'],
|
||||
monitoring: ['usage', 'audit', 'stats'],
|
||||
@@ -18,7 +18,7 @@ const ADMIN_LABELS = {
|
||||
users: 'Users', teams: 'Teams', groups: 'Groups',
|
||||
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
|
||||
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
||||
workflows: 'Workflows',
|
||||
workflows: 'Workflows', tasks: 'Tasks',
|
||||
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces',
|
||||
usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||
};
|
||||
@@ -52,6 +52,7 @@ const ADMIN_LOADERS = {
|
||||
stats: () => UI.loadAdminStats(),
|
||||
channels: () => UI.loadAdminChannels(),
|
||||
workflows: () => typeof _loadAdminWorkflows === 'function' ? _loadAdminWorkflows() : null,
|
||||
tasks: () => typeof _loadAdminTasks === 'function' ? _loadAdminTasks() : null,
|
||||
};
|
||||
|
||||
// Find which category a section belongs to
|
||||
@@ -924,6 +925,8 @@ Object.assign(UI, {
|
||||
'persona.create': 'Create personas',
|
||||
'persona.manage': 'Edit / delete team personas',
|
||||
'workflow.create': 'Create workflows',
|
||||
'task.create': 'Create scheduled tasks',
|
||||
'task.admin': 'Manage all tasks + config',
|
||||
'admin.view': 'Read-only admin access',
|
||||
'token.unlimited': 'Bypass token budgets',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user