Merge remote-tracking branch 'origin/pr-586' into codex/pr-557-589-security-integration
This commit is contained in:
@@ -9,6 +9,9 @@ import (
|
||||
|
||||
const cloudAgentMaxEmptyOutputNudges = 2
|
||||
|
||||
// cloudAgentMaxEmptyOutputEscalations 是"空输出升级重试"次数:催办没用时改为关思考 + 放大预算。
|
||||
const cloudAgentMaxEmptyOutputEscalations = 1
|
||||
|
||||
// cloudAgentEmptyModelOutput 判断模型任务是不是「上游成功、但正文与工具调用都为空」。
|
||||
// 开思考时偶发只流 reasoning、可见正文和工具调用都空,解析层会归为「没有返回内容」。
|
||||
func cloudAgentEmptyModelOutput(task *model.Task) bool {
|
||||
@@ -28,3 +31,21 @@ func (s *Service) correctCloudAgentEmptyOutput(run *model.CloudAgentExecution, s
|
||||
return cloudAgentSave(current, state)
|
||||
})
|
||||
}
|
||||
|
||||
// correctCloudAgentEmptyOutputEscalation 是空输出的兜底:催办无效时关掉上游思考并放大输出预算,
|
||||
// 重新发同一步(不追加催办消息,避免模型误以为出现了新任务)。
|
||||
func (s *Service) correctCloudAgentEmptyOutputEscalation(run *model.CloudAgentExecution, state *cloudAgentRuntime) error {
|
||||
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
|
||||
state.EmptyOutputEscalated++
|
||||
state.ForceThinkingOff = true
|
||||
state.BoostStepOutputBudget = true
|
||||
state.ActiveTaskID = ""
|
||||
state.Calls = nil
|
||||
state.CallIndex = 0
|
||||
state.event(run.ID, "model_failure_recovered", map[string]any{
|
||||
"reason": "empty_output_escalated",
|
||||
"text": "上游连续返回空内容;已关闭思考并放大输出预算重试同一步",
|
||||
})
|
||||
return cloudAgentSave(current, state)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,6 +82,18 @@ type cloudAgentRuntime struct {
|
||||
TransientReferences map[string]cloudAgentTransientReference `json:"transientReferences,omitempty"`
|
||||
InterjectionIDs []string `json:"interjectionIds,omitempty"`
|
||||
Events []CloudAgentEvent `json:"events"`
|
||||
// EmptyOutputEscalated 记录"空输出已经升级重试过几次"(关思考 + 放大输出预算)。
|
||||
EmptyOutputEscalated int `json:"emptyOutputEscalated,omitempty"`
|
||||
// StepTimeoutEscalated 记录"单步墙钟到点后已经关思考重试过几次"。
|
||||
StepTimeoutEscalated int `json:"stepTimeoutEscalated,omitempty"`
|
||||
// ForceThinkingOff 让本步请求强制关闭上游思考:思考模型偶发把整个输出预算花在推理上,
|
||||
// 结果正文与工具调用皆空(实测 output_tokens 正好等于 maxOutputTokens)。
|
||||
ForceThinkingOff bool `json:"forceThinkingOff,omitempty"`
|
||||
// BoostStepOutputBudget 让本步请求使用放大后的输出预算(配合关思考重试)。
|
||||
BoostStepOutputBudget bool `json:"boostStepOutputBudget,omitempty"`
|
||||
// StepLimits 是本步实际生效的执行边界(管理员策略解析结果),只用于构造请求:
|
||||
// 不进状态 JSON——每次推进都按当时的策略重新解析,改配置无需重发本轮。
|
||||
StepLimits cloudAgentStepLimits `json:"-"`
|
||||
}
|
||||
|
||||
type cloudAgentTransientReference struct {
|
||||
@@ -105,7 +117,7 @@ func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgent
|
||||
canonical := input.Requests.Canonical
|
||||
canonical.SystemPrompt = stripCloudAgentPlanBlock(canonical.SystemPrompt)
|
||||
canonical.Messages = stripCloudAgentRuntimeContext(canonical.Messages)
|
||||
state := cloudAgentRuntime{Request: initial.Request, Policy: initial.Policy, ParentID: initial.ParentID, Fingerprint: initial.Fingerprint, CreativeAnchor: initial.CreativeAnchor, TextHistory: input.TextHistory, Skills: initial.Skills, Profile: initial.Profile, Canonical: canonical, ActiveTaskID: task.ID, TaskIDs: []string{task.ID}, Step: 1, Decisions: map[string]string{}, Plan: initial.Plan, Events: []CloudAgentEvent{}}
|
||||
state := cloudAgentRuntime{Request: initial.Request, Policy: initial.Policy, ParentID: initial.ParentID, Fingerprint: initial.Fingerprint, CreativeAnchor: initial.CreativeAnchor, TextHistory: input.TextHistory, Skills: initial.Skills, Profile: initial.Profile, Canonical: canonical, ActiveTaskID: task.ID, TaskIDs: []string{task.ID}, Step: 1, Decisions: map[string]string{}, Plan: initial.Plan, Events: []CloudAgentEvent{}, StepLimits: s.cloudAgentStepLimits()}
|
||||
if len(initial.Skills) > 0 {
|
||||
state.event(task.ID, "tool_completed", map[string]any{"toolName": "skills_load", "text": fmt.Sprintf("已启用 %d 个技能,正文将按需读取", len(initial.Skills))})
|
||||
}
|
||||
@@ -595,6 +607,8 @@ func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error)
|
||||
}
|
||||
return s.terminateCloudAgent(run, "Agent 运行状态损坏,本轮已停止")
|
||||
}
|
||||
// 单步边界每次推进都重新解析:管理员改配置后,正在跑的这一轮下一步就用新值。
|
||||
state.StepLimits = s.cloudAgentStepLimits()
|
||||
if state.ActiveTaskID != "" {
|
||||
task, err := s.repo.TaskForUser(run.UserID, state.ActiveTaskID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -652,8 +666,18 @@ func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error)
|
||||
if task.Status != model.TaskStatusSucceeded && cloudAgentTruncatedToolArguments(task) {
|
||||
return s.correctCloudAgentTruncatedCalls(run, &state)
|
||||
}
|
||||
if cloudAgentEmptyModelOutput(task) && state.EmptyOutputNudged < cloudAgentMaxEmptyOutputNudges {
|
||||
return s.correctCloudAgentEmptyOutput(run, &state)
|
||||
if cloudAgentEmptyModelOutput(task) {
|
||||
if state.EmptyOutputNudged < cloudAgentMaxEmptyOutputNudges {
|
||||
return s.correctCloudAgentEmptyOutput(run, &state)
|
||||
}
|
||||
// 催过仍然空:改为"关思考 + 放大输出预算"重试同一步,而不是把整轮判死。
|
||||
if state.EmptyOutputEscalated < cloudAgentMaxEmptyOutputEscalations {
|
||||
return s.correctCloudAgentEmptyOutputEscalation(run, &state)
|
||||
}
|
||||
}
|
||||
// 单步墙钟到点同样是可恢复失败:关思考重试一次,而不是把整轮判死。
|
||||
if cloudAgentStepTimedOut(task) && state.StepTimeoutEscalated < cloudAgentMaxStepTimeoutEscalations {
|
||||
return s.correctCloudAgentStepTimeout(run, &state)
|
||||
}
|
||||
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
|
||||
if task.Status != model.TaskStatusSucceeded {
|
||||
@@ -726,7 +750,11 @@ func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error)
|
||||
return contextErr
|
||||
}
|
||||
s.attachCloudAgentLessons(&canonical, run.UserID, cloudAgentLessonTaskText(&state))
|
||||
input := map[string]any{"mode": "text", "prompt": state.Request.Prompt, "agentRequests": map[string]any{"canonical": canonical}, "config": map[string]any{"channelId": state.Request.ChannelID, "channelModelKey": state.Request.ChannelModelKey, "model": firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model)}, "textOptions": map[string]any{"stream": true, "thinking": cloudAgentReasoningEnabled(state.Policy.ReasoningMode)}}
|
||||
// 单步输出上限与思考开关:默认按策略给每一步带上界(不带上界时上游按剩余上下文放行,
|
||||
// 思考模型可以把单步拖到几分钟);空输出升级重试时改为关思考 + 放大预算。
|
||||
stepThinking := cloudAgentReasoningEnabled(state.Policy.ReasoningMode) && !state.ForceThinkingOff
|
||||
stepOutputTokens := cloudAgentStepOutputBudget(state.StepLimits, state.BoostStepOutputBudget)
|
||||
input := map[string]any{"mode": "text", "prompt": state.Request.Prompt, "agentRequests": map[string]any{"canonical": canonical}, "config": map[string]any{"channelId": state.Request.ChannelID, "channelModelKey": state.Request.ChannelModelKey, "model": firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model)}, "textOptions": map[string]any{"stream": true, "thinking": stepThinking, "maxOutputTokens": stepOutputTokens}}
|
||||
tokens, tokenErr := cloudAgentRequestEstimatedTokens(&canonical)
|
||||
if tokenErr != nil {
|
||||
return s.failCloudAgent(run, &state, "模型上下文估算失败,请稍后重试")
|
||||
@@ -861,6 +889,11 @@ func cloudAgentModelFailure(task *model.Task) (string, string) {
|
||||
}
|
||||
raw := strings.ToLower(task.Error)
|
||||
switch {
|
||||
case strings.Contains(task.Error, "没有返回内容"):
|
||||
// 思考模型的典型失败:整个输出预算被推理吃掉,正文与工具调用皆空。
|
||||
detail, reason = "上游连续返回空内容(通常是思考占满输出预算);已自动关思考并放大预算重试仍失败,建议换用非思考模型或调小上下文", "model_empty_output"
|
||||
case strings.Contains(task.Error, cloudAgentStepTimeoutError):
|
||||
detail, reason = "单步模型调用超过执行时限仍未返回(长思考或上下文过大时常见);已自动关思考重试仍超时,可在管理端调大 Agent 单步超时", "model_step_timeout"
|
||||
case strings.Contains(raw, "connection reset by peer"):
|
||||
detail, reason = "模型连接被对端或中间网络设备重置", "model_connection_reset"
|
||||
case strings.Contains(raw, "timeout"), strings.Contains(raw, "deadline exceeded"):
|
||||
|
||||
55
backend/internal/app/cloud_agent_step_limits.go
Normal file
55
backend/internal/app/cloud_agent_step_limits.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/platform"
|
||||
)
|
||||
|
||||
// 画布 Agent 单步的执行边界:一次模型调用最多输出多少 token、最多等多久。
|
||||
// 两个值都来自运行时策略(管理端可改、环境变量可给部署级默认值),"0"是各自的关闭档位。
|
||||
|
||||
// cloudAgentStepOperation 是一次"模型调用"任务的操作名。它同时是任务中心里区分
|
||||
// "这一条任务是 Agent 的某一步"的依据。
|
||||
const cloudAgentStepOperation = "cloud_agent_step"
|
||||
|
||||
// cloudAgentStepBoostFallbackTokens 是"不限制输出"(策略值为 0)时放大重试用的预算:
|
||||
// 不限制的本意是"让模型写完",重试却必须有个上界,否则一次卡住的调用会一直占着单步墙钟。
|
||||
const cloudAgentStepBoostFallbackTokens = 32_768
|
||||
|
||||
// cloudAgentStepLimits 是一次模型调用实际生效的执行边界。
|
||||
type cloudAgentStepLimits struct {
|
||||
// OutputTokens 是本次调用的输出上限;0 表示不限制(只有 Timeout 兜底)。
|
||||
OutputTokens int
|
||||
// Timeout 是单步墙钟:策略给了秒级值时用它,否则沿用文本任务超时。
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// cloudAgentStepLimits 解析当前生效的单步边界。策略读取失败时退回出厂默认值:
|
||||
// 拿到一个确定的上界,好过让一次调用无限跑下去。
|
||||
func (s *Service) cloudAgentStepLimits() cloudAgentStepLimits {
|
||||
policy, err := s.runtimeConcurrencySetting()
|
||||
if err != nil {
|
||||
policy = defaultRuntimePolicy().Task
|
||||
}
|
||||
limits := cloudAgentStepLimits{OutputTokens: policy.AgentStepMaxOutputTokens}
|
||||
if policy.AgentStepTimeoutSeconds > 0 {
|
||||
limits.Timeout = time.Duration(policy.AgentStepTimeoutSeconds) * time.Second
|
||||
} else {
|
||||
limits.Timeout = time.Duration(policy.TextTimeoutMinutes) * time.Minute
|
||||
}
|
||||
return limits
|
||||
}
|
||||
|
||||
// cloudAgentStepOutputBudget 把生效上限折算成本步请求要带的 maxOutputTokens:
|
||||
// 放大档(空输出升级重试)在原值上翻倍并以硬上限封顶;原值为 0(不限制)时用兜底值,
|
||||
// 让重试仍然有界。
|
||||
func cloudAgentStepOutputBudget(limits cloudAgentStepLimits, boosted bool) int {
|
||||
if !boosted {
|
||||
return limits.OutputTokens
|
||||
}
|
||||
if limits.OutputTokens <= 0 {
|
||||
return cloudAgentStepBoostFallbackTokens
|
||||
}
|
||||
return min(limits.OutputTokens*2, platform.MaxRuntimeAgentStepOutputTokens)
|
||||
}
|
||||
57
backend/internal/app/cloud_agent_step_timeout.go
Normal file
57
backend/internal/app/cloud_agent_step_timeout.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
)
|
||||
|
||||
// cloudAgentStepTimeoutError 是一次画布 Agent 模型调用被单步墙钟掐断时写进任务错误的标记。
|
||||
// 秒级超时(策略 AgentStepTimeoutSeconds)与文本任务超时都会走到这里,运行期按这个标记
|
||||
// 把它当成"可恢复失败"处理,而不是整轮判死。
|
||||
const cloudAgentStepTimeoutError = "画布 Agent 单步模型调用超时"
|
||||
|
||||
// cloudAgentMaxStepTimeoutEscalations 是单步超时的重试次数:超时几乎总是思考阶段在长时间
|
||||
// 无输出,因此重试的手段是关掉思考(更省时的路径),只给一次机会。
|
||||
const cloudAgentMaxStepTimeoutEscalations = 1
|
||||
|
||||
// cloudAgentModelOperation 判断任务是不是"画布 Agent 的一次模型调用":根任务(第一步)与
|
||||
// 后续每一步都属于同一类,都要按单步口径计时。
|
||||
func cloudAgentModelOperation(task *model.Task) bool {
|
||||
if task == nil {
|
||||
return false
|
||||
}
|
||||
switch task.Operation {
|
||||
case cloudAgentOperation, cloudAgentStepOperation:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// cloudAgentStepTimedOut 判断这一步是不是被单步墙钟中止的。
|
||||
func cloudAgentStepTimedOut(task *model.Task) bool {
|
||||
if task == nil || task.Status == model.TaskStatusSucceeded {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(task.Error, cloudAgentStepTimeoutError)
|
||||
}
|
||||
|
||||
// correctCloudAgentStepTimeout 是单步超时的兜底:关掉上游思考并重发同一步。
|
||||
// 超时不改输出预算——墙钟是这一档的约束,放大预算只会让重试更容易再次撞上墙钟。
|
||||
func (s *Service) correctCloudAgentStepTimeout(run *model.CloudAgentExecution, state *cloudAgentRuntime) error {
|
||||
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
|
||||
state.StepTimeoutEscalated++
|
||||
state.ForceThinkingOff = true
|
||||
state.BoostStepOutputBudget = false
|
||||
state.ActiveTaskID = ""
|
||||
state.Calls = nil
|
||||
state.CallIndex = 0
|
||||
state.event(run.ID, "model_failure_recovered", map[string]any{
|
||||
"reason": "step_timeout_retried",
|
||||
"text": "上一步模型调用超时;已关闭思考重试同一步",
|
||||
})
|
||||
return cloudAgentSave(current, state)
|
||||
})
|
||||
}
|
||||
294
backend/internal/app/cloud_agent_step_timeout_test.go
Normal file
294
backend/internal/app/cloud_agent_step_timeout_test.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package app
|
||||
|
||||
// 自研:画布 Agent 单步边界(输出上限 + 秒级墙钟)的可配置化与超时可恢复。
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/platform"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
)
|
||||
|
||||
func TestCloudAgentStepOutputBudgetFollowsPolicy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
outputTokens int
|
||||
boosted bool
|
||||
expectedValue int
|
||||
}{
|
||||
{name: "默认上限", outputTokens: platform.DefaultRuntimeAgentStepOutputTokens, expectedValue: 16_384},
|
||||
{name: "空输出放大档翻倍", outputTokens: platform.DefaultRuntimeAgentStepOutputTokens, boosted: true, expectedValue: 32_768},
|
||||
{name: "自定义上限", outputTokens: 32_000, expectedValue: 32_000},
|
||||
{name: "自定义上限放大后封顶", outputTokens: 100_000, boosted: true, expectedValue: platform.MaxRuntimeAgentStepOutputTokens},
|
||||
{name: "不限制时原值即 0", outputTokens: 0, expectedValue: 0},
|
||||
{name: "不限制时放大档必须有界", outputTokens: 0, boosted: true, expectedValue: cloudAgentStepBoostFallbackTokens},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
got := cloudAgentStepOutputBudget(cloudAgentStepLimits{OutputTokens: item.outputTokens}, item.boosted)
|
||||
if got != item.expectedValue {
|
||||
t.Fatalf("budget = %d, want %d", got, item.expectedValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskExecutionTimeoutPrefersAgentStepSeconds(t *testing.T) {
|
||||
policy := defaultRuntimePolicy().Task
|
||||
policy.TextTimeoutMinutes = 8
|
||||
policy.AgentStepTimeoutSeconds = 90
|
||||
step := &model.Task{Type: "canvas_text", Operation: cloudAgentStepOperation}
|
||||
if got := taskExecutionTimeout(step, policy); got != 90*time.Second {
|
||||
t.Fatalf("agent step timeout = %s, want 90s", got)
|
||||
}
|
||||
// 没配秒级值时沿用文本任务超时(旧行为不变)。
|
||||
policy.AgentStepTimeoutSeconds = 0
|
||||
if got := taskExecutionTimeout(step, policy); got != 8*time.Minute {
|
||||
t.Fatalf("fallback timeout = %s, want 8m", got)
|
||||
}
|
||||
// 非 Agent 的文本任务不受这个开关影响。
|
||||
policy.AgentStepTimeoutSeconds = 90
|
||||
plain := &model.Task{Type: "canvas_text", Operation: "canvas_text_generate"}
|
||||
if got := taskExecutionTimeout(plain, policy); got != 8*time.Minute {
|
||||
t.Fatalf("plain text timeout = %s, want 8m", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 单步边界必须真的来自策略:管理端改完,运行期下一步就用新值,不需要重发本轮。
|
||||
func TestCloudAgentStepLimitsFollowAdminPolicy(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}, &model.AdminAuditEvent{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(repository.New(db), t.TempDir())
|
||||
actor := &model.User{ID: "admin", Role: model.UserRoleAdmin}
|
||||
|
||||
defaults := svc.cloudAgentStepLimits()
|
||||
if defaults.OutputTokens != platform.DefaultRuntimeAgentStepOutputTokens || defaults.Timeout != 8*time.Minute {
|
||||
t.Fatalf("default step limits = %+v", defaults)
|
||||
}
|
||||
|
||||
policy := defaultRuntimePolicy()
|
||||
policy.Task.AgentStepMaxOutputTokens = 32_768
|
||||
policy.Task.AgentStepTimeoutSeconds = 150
|
||||
if _, err := svc.UpdateRuntimePolicySetting(actor, policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated := svc.cloudAgentStepLimits()
|
||||
if updated.OutputTokens != 32_768 || updated.Timeout != 150*time.Second {
|
||||
t.Fatalf("updated step limits = %+v", updated)
|
||||
}
|
||||
|
||||
// 0 的两个含义:输出不限制、超时沿用文本任务超时。
|
||||
policy.Task.AgentStepMaxOutputTokens = 0
|
||||
policy.Task.AgentStepTimeoutSeconds = 0
|
||||
policy.Task.TextTimeoutMinutes = 12
|
||||
if _, err := svc.UpdateRuntimePolicySetting(actor, policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unbounded := svc.cloudAgentStepLimits()
|
||||
if unbounded.OutputTokens != 0 || unbounded.Timeout != 12*time.Minute {
|
||||
t.Fatalf("unbounded step limits = %+v", unbounded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimePolicyRejectsOutOfRangeAgentStepLimits(t *testing.T) {
|
||||
policy := defaultRuntimePolicy()
|
||||
policy.Task.AgentStepMaxOutputTokens = platform.MinRuntimeAgentStepOutputTokens - 1
|
||||
if err := validateRuntimePolicy(policy); err == nil {
|
||||
t.Fatal("过小的单步输出上限应被拒绝")
|
||||
}
|
||||
policy = defaultRuntimePolicy()
|
||||
policy.Task.AgentStepMaxOutputTokens = platform.MaxRuntimeAgentStepOutputTokens + 1
|
||||
if err := validateRuntimePolicy(policy); err == nil {
|
||||
t.Fatal("过大的单步输出上限应被拒绝")
|
||||
}
|
||||
policy = defaultRuntimePolicy()
|
||||
policy.Task.AgentStepMaxOutputTokens = 0
|
||||
if err := validateRuntimePolicy(policy); err != nil {
|
||||
t.Fatalf("0(不限制)应当合法: %v", err)
|
||||
}
|
||||
policy = defaultRuntimePolicy()
|
||||
policy.Task.AgentStepTimeoutSeconds = platform.MinRuntimeAgentStepTimeoutSeconds - 1
|
||||
if err := validateRuntimePolicy(policy); err == nil {
|
||||
t.Fatal("过小的单步超时(非 0)应被拒绝")
|
||||
}
|
||||
policy = defaultRuntimePolicy()
|
||||
policy.Task.AgentStepTimeoutSeconds = platform.MaxRuntimeAgentStepTimeoutSeconds + 1
|
||||
if err := validateRuntimePolicy(policy); err == nil {
|
||||
t.Fatal("过大的单步超时应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// 旧配置 JSON 没有这两个字段时按默认值回填:不需要数据迁移。
|
||||
func TestRuntimePolicyBackfillsAgentStepLimitsForLegacyJSON(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := defaultRuntimePolicy()
|
||||
encoded, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(encoded, &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := value["task"].(map[string]any)
|
||||
delete(task, "agentStepMaxOutputTokens")
|
||||
delete(task, "agentStepTimeoutSeconds")
|
||||
trimmed, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.SystemSetting{Key: runtimePolicySettingKey, ValueJSON: string(trimmed)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(repository.New(db), t.TempDir())
|
||||
effective, err := svc.RuntimePolicy()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if effective.Task.AgentStepMaxOutputTokens != platform.DefaultRuntimeAgentStepOutputTokens {
|
||||
t.Fatalf("legacy json 未回填单步输出上限: %d", effective.Task.AgentStepMaxOutputTokens)
|
||||
}
|
||||
if effective.Task.AgentStepTimeoutSeconds != platform.DefaultRuntimeAgentStepTimeout {
|
||||
t.Fatalf("legacy json 未回填单步超时: %d", effective.Task.AgentStepTimeoutSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// 单步墙钟到点不再把整轮判死:关思考重试一次;重试仍超时才失败,且失败原因可读。
|
||||
func TestCloudAgentStepTimeoutRetriesThenFailsWithReadableReason(t *testing.T) {
|
||||
s, db, root := reliableAgentRoot(t)
|
||||
failStepTask(t, db, root.ID)
|
||||
|
||||
if err := s.advanceCloudAgentByID("user", root.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, state := agentInterjectionState(t, s, root.ID)
|
||||
if state.StepTimeoutEscalated != 1 || !state.ForceThinkingOff {
|
||||
t.Fatalf("超时后应关思考重试: escalated=%d forceThinkingOff=%v", state.StepTimeoutEscalated, state.ForceThinkingOff)
|
||||
}
|
||||
if run.Status != "running" {
|
||||
t.Fatalf("超时应先重试而不是判死,实际 status=%s(%s)", run.Status, run.FailureMessage)
|
||||
}
|
||||
if !agentHasEventWithReason(state, "model_failure_recovered", "step_timeout_retried") {
|
||||
t.Fatal("缺少可读的自动重试事件")
|
||||
}
|
||||
|
||||
// 重试那一步:必须真的关掉思考,并且带着生效的输出上限。
|
||||
if err := s.advanceCloudAgentByID("user", root.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, retried := agentInterjectionState(t, s, root.ID)
|
||||
if retried.ActiveTaskID == "" {
|
||||
t.Fatal("重试没有重新发起模型调用")
|
||||
}
|
||||
task, err := s.repo.TaskForUser("user", retried.ActiveTaskID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options := stepTaskTextOptions(t, s, task)
|
||||
if options["thinking"] != false {
|
||||
t.Fatalf("重试请求没有关思考: %+v", options)
|
||||
}
|
||||
if options["maxOutputTokens"] != float64(platform.DefaultRuntimeAgentStepOutputTokens) {
|
||||
t.Fatalf("重试请求的输出上限 = %v", options["maxOutputTokens"])
|
||||
}
|
||||
|
||||
// 重试也超时 → 整轮失败,原因指向可配置的那条线。
|
||||
failStepTask(t, db, retried.ActiveTaskID)
|
||||
if err := s.advanceCloudAgentByID("user", root.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, state = agentInterjectionState(t, s, root.ID)
|
||||
if run.Status != "failed" {
|
||||
t.Fatalf("重试仍超时应结束本轮,实际 status=%s", run.Status)
|
||||
}
|
||||
if !strings.Contains(run.FailureMessage, "单步模型调用超过执行时限") {
|
||||
t.Fatalf("失败文案没有解释单步超时: %s", run.FailureMessage)
|
||||
}
|
||||
if !agentHasEventWithReason(state, "run_failed", "model_step_timeout") {
|
||||
t.Fatal("失败事件缺少 model_step_timeout 原因")
|
||||
}
|
||||
}
|
||||
|
||||
func failStepTask(t *testing.T, db *gorm.DB, taskID string) {
|
||||
t.Helper()
|
||||
if err := db.Model(&model.Task{}).Where("id = ?", taskID).Updates(map[string]any{
|
||||
"status": model.TaskStatusFailed,
|
||||
"error": cloudAgentStepTimeoutError + ",已中止这一步",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func stepTaskTextOptions(t *testing.T, s *Service, task *model.Task) map[string]any {
|
||||
t.Helper()
|
||||
decrypted, err := s.decryptTaskInputJSON(task.InputJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var input struct {
|
||||
TextOptions map[string]any `json:"textOptions"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(decrypted), &input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(input.TextOptions) == 0 {
|
||||
t.Fatalf("任务缺少 textOptions: %s", decrypted)
|
||||
}
|
||||
return input.TextOptions
|
||||
}
|
||||
|
||||
func agentHasEventWithReason(state cloudAgentRuntime, eventType, reason string) bool {
|
||||
for _, event := range state.Events {
|
||||
if event.Type == eventType && event.Payload["reason"] == reason {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// agentEventValue 取最近一条指定类型事件里的某个字段值。
|
||||
func agentEventValue(state cloudAgentRuntime, eventType, key string) (any, bool) {
|
||||
for i := len(state.Events) - 1; i >= 0; i-- {
|
||||
if state.Events[i].Type != eventType {
|
||||
continue
|
||||
}
|
||||
value, ok := state.Events[i].Payload[key]
|
||||
return value, ok
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 执行时限到点必须按失败收尾(而不是被"租约丢失"提前返回)。
|
||||
func TestAgentExecutionDeadlineCountsAsTaskFailure(t *testing.T) {
|
||||
policy := defaultRuntimePolicy().Task
|
||||
policy.AgentStepTimeoutSeconds = 30
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
<-ctx.Done()
|
||||
task := &model.Task{Type: "canvas_text", Operation: cloudAgentStepOperation}
|
||||
if got := taskExecutionTimeout(task, policy); got != 30*time.Second {
|
||||
t.Fatalf("timeout = %s", got)
|
||||
}
|
||||
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
t.Fatal("到期后 ctx 必须报告 DeadlineExceeded(worker 据此区分租约丢失)")
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,10 @@ type canvasGenerationInput struct {
|
||||
type canvasTextOptions struct {
|
||||
Stream *bool `json:"stream"`
|
||||
Thinking bool `json:"thinking"`
|
||||
// MaxOutputTokens 是本次调用的输出上限(思考 + 正文 + 工具参数)。
|
||||
// 画布 Agent 的每一步都带上限:不设时上游按"剩余上下文"放行,思考模型可以把单步
|
||||
// 拖到几分钟(实测 output_tokens 正好吃满可用预算、正文与工具调用皆空);0 表示不限制。
|
||||
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
|
||||
}
|
||||
|
||||
type agentToolRequests struct {
|
||||
|
||||
@@ -58,6 +58,7 @@ func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[str
|
||||
}
|
||||
body["model"] = input.Config.Model
|
||||
applyTextThinking(body, input, protocol)
|
||||
applyAgentOutputLimit(body, agentStepOutputLimit(input), protocol)
|
||||
normalizeAgentToolChoice(body, input, protocol)
|
||||
result, err := postAgentRequest(ctx, input, path, body, protocol)
|
||||
if protocol == "chat-completion" && isAgentToolChoiceCompatibilityError(err) {
|
||||
@@ -90,6 +91,39 @@ func postAgentRequest(ctx context.Context, input canvasGenerationInput, path str
|
||||
return parseAgentToolPayload(payload, protocol)
|
||||
}
|
||||
|
||||
// agentStepOutputLimit 取本次 Agent 调用的输出上限。
|
||||
//
|
||||
// 两个来源语义相同但通道不同:textOptions.maxOutputTokens 是任务信封里下发的策略值
|
||||
// (画布 Agent 每步按运行时策略给,persist 在任务输入里,重启后仍在);input.MaxOutputTokens
|
||||
// 是进程内直传的旧入口(渠道模型能力声明)。都为 0 时不写上限字段,交给上游按剩余上下文放行。
|
||||
// 都非零时取较小值:策略上限不该超过模型自己声明的物理上限。
|
||||
func agentStepOutputLimit(input canvasGenerationInput) int {
|
||||
limits := []int{input.TextOptions.MaxOutputTokens, input.MaxOutputTokens}
|
||||
limit := 0
|
||||
for _, candidate := range limits {
|
||||
if candidate <= 0 {
|
||||
continue
|
||||
}
|
||||
if limit == 0 || candidate < limit {
|
||||
limit = candidate
|
||||
}
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// applyAgentOutputLimit 按协议写入输出上限字段名:Claude 与 Chat Completions 用 max_tokens,
|
||||
// Responses 用 max_output_tokens。
|
||||
func applyAgentOutputLimit(body map[string]interface{}, limit int, protocol string) {
|
||||
if limit <= 0 {
|
||||
return
|
||||
}
|
||||
field := "max_tokens"
|
||||
if protocol == "responses" {
|
||||
field = "max_output_tokens"
|
||||
}
|
||||
applyTextOutputLimit(body, limit, field)
|
||||
}
|
||||
|
||||
func runDeclarativeAgentTask(ctx context.Context, input canvasGenerationInput, adapter protocol.AgentAdapter) (map[string]interface{}, error) {
|
||||
wire := input.Config.InterfaceType
|
||||
if wire == string(model.ChannelInterfaceOpenAIResponse) {
|
||||
@@ -118,6 +152,7 @@ func runDeclarativeAgentTask(ctx context.Context, input canvasGenerationInput, a
|
||||
return nil, errors.New("声明式 Agent 请求体必须是 JSON 对象")
|
||||
}
|
||||
applyTextThinking(body, input, wire)
|
||||
applyAgentOutputLimit(body, agentStepOutputLimit(input), wire)
|
||||
normalizeAgentToolChoice(body, input, wire)
|
||||
spec.Body = body
|
||||
if input.StreamText {
|
||||
|
||||
@@ -137,7 +137,7 @@ func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task, globalSlot
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), taskExecutionTimeoutWithPolicy(task.Type, policy.Task))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), taskExecutionTimeout(task, policy.Task))
|
||||
defer cancel()
|
||||
leaseDone := make(chan struct{})
|
||||
leaseLost := make(chan error, 1)
|
||||
@@ -227,11 +227,17 @@ func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task, globalSlot
|
||||
if code, _ := ChannelSlotFailureDetails(err); code != "" {
|
||||
channelSlotFailedBeforeRequest = true
|
||||
}
|
||||
// 续租使用独立 context;即使执行同时超时,真实租约失效仍必须阻止写入。
|
||||
// 续租使用独立 context,真实租约失效必须阻止写入;但执行时限到点
|
||||
// (单步墙钟或任务超时)本身不是"租约丢失":续租可能正好被同一个时限打断,
|
||||
// 此时必须继续走失败收尾,否则任务停在 running 被反复重跑。
|
||||
deadlineExpired := errors.Is(ctx.Err(), context.DeadlineExceeded)
|
||||
select {
|
||||
case leaseErr := <-leaseLost:
|
||||
_ = s.log(task.UserID, task.ID, "warn", "任务租约失效,等待其他 worker 恢复", leaseErr.Error())
|
||||
return leaseErr
|
||||
if !deadlineExpired {
|
||||
_ = s.log(task.UserID, task.ID, "warn", "任务租约失效,等待其他 worker 恢复", leaseErr.Error())
|
||||
return leaseErr
|
||||
}
|
||||
_ = s.log(task.UserID, task.ID, "warn", "任务执行时限到点,按失败收尾(不视为租约丢失)", leaseErr.Error())
|
||||
default:
|
||||
}
|
||||
decryptedInput, decryptErr := s.decryptTaskInputJSON(task.InputJSON)
|
||||
@@ -252,8 +258,14 @@ func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task, globalSlot
|
||||
if newAPIChannel2TaskSyncExpired(*task, err, time.Now()) {
|
||||
err = errors.New("上游任务长时间未同步,已停止自动查询,请确认渠道任务状态后重试。")
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
err = errors.New(taskTimeoutMessage(task.Type))
|
||||
if errors.Is(err, context.DeadlineExceeded) || deadlineExpired {
|
||||
// 画布 Agent 的单步超时是可恢复事件(运行期会关思考重试同一步),
|
||||
// 因此必须与"任务执行超时"区分开,否则只能整轮判死。
|
||||
if cloudAgentModelOperation(task) {
|
||||
err = errors.New(cloudAgentStepTimeoutError + ",已中止这一步")
|
||||
} else {
|
||||
err = errors.New(taskTimeoutMessage(task.Type))
|
||||
}
|
||||
}
|
||||
s.noteAgentMemoryCompactTask(*task, nil, err)
|
||||
return terminal.handleExecutionFailure(task, err, providerSucceeded, channelSlotFailedBeforeRequest)
|
||||
@@ -307,6 +319,21 @@ func taskLeaseRenewContext(parent context.Context) (context.Context, context.Can
|
||||
return context.WithTimeout(context.WithoutCancel(parent), 5*time.Second)
|
||||
}
|
||||
|
||||
// taskExecutionTimeout 解析一次任务的执行墙钟:画布 Agent 的单步调用可以配秒级超时
|
||||
// (AgentStepTimeoutSeconds),没配时沿用文本任务超时。秒级粒度是必要的——一轮里每一步
|
||||
// 都是分钟级的调用,分钟粒度改不动"某一步卡住"的体验。
|
||||
// 超时的表现是任务错误里带 cloudAgentStepTimeoutError 标记,运行期据此关思考重试同一步,
|
||||
// 而不是把整轮判死(见 cloud_agent_step_timeout.go)。
|
||||
func taskExecutionTimeout(task *model.Task, policy RuntimeTaskPolicy) time.Duration {
|
||||
if task != nil && cloudAgentModelOperation(task) && policy.AgentStepTimeoutSeconds > 0 {
|
||||
return time.Duration(policy.AgentStepTimeoutSeconds) * time.Second
|
||||
}
|
||||
if task == nil {
|
||||
return time.Duration(policy.DefaultTimeoutMinutes) * time.Minute
|
||||
}
|
||||
return taskExecutionTimeoutWithPolicy(task.Type, policy)
|
||||
}
|
||||
|
||||
func taskExecutionTimeoutWithPolicy(taskType string, policy RuntimeTaskPolicy) time.Duration {
|
||||
switch {
|
||||
case strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_"):
|
||||
|
||||
@@ -26,6 +26,30 @@ const (
|
||||
maxRuntimeTimeoutMinutes = 9_999
|
||||
)
|
||||
|
||||
// 画布 Agent 单步的输出上限与执行时限。上限是"每次模型调用"的边界(思考 + 正文 + 工具参数),
|
||||
// 时限是"这一次调用最多等多久"的墙钟;两者一起决定单步最坏耗时。0 在两个口径下都是"该边界不生效":
|
||||
// 上限为 0 表示不限制输出(只由执行时限兜底),时限为 0 表示沿用文本任务超时。
|
||||
const (
|
||||
MinRuntimeAgentStepOutputTokens = 256
|
||||
MaxRuntimeAgentStepOutputTokens = 131_072
|
||||
DefaultRuntimeAgentStepOutputTokens = 16_384
|
||||
MinRuntimeAgentStepTimeoutSeconds = 30
|
||||
MaxRuntimeAgentStepTimeoutSeconds = 3_600
|
||||
DefaultRuntimeAgentStepTimeout = 0
|
||||
)
|
||||
|
||||
// agentStepMaxOutputTokensDefault 读取部署级覆盖并按同一套范围夹取;环境变量无法表达"不限制"(0),
|
||||
// 那个档位只在管理端配置里设置。
|
||||
func agentStepMaxOutputTokensDefault() int {
|
||||
value := envInt("CANVAS_AGENT_STEP_MAX_OUTPUT_TOKENS", DefaultRuntimeAgentStepOutputTokens)
|
||||
return min(max(value, MinRuntimeAgentStepOutputTokens), MaxRuntimeAgentStepOutputTokens)
|
||||
}
|
||||
|
||||
func agentStepTimeoutSecondsDefault() int {
|
||||
value := envInt("CANVAS_AGENT_STEP_TIMEOUT_SECONDS", DefaultRuntimeAgentStepTimeout)
|
||||
return min(value, MaxRuntimeAgentStepTimeoutSeconds)
|
||||
}
|
||||
|
||||
type RuntimeResourcePolicy struct {
|
||||
ResourceUploadMB int64 `json:"resourceUploadMB"`
|
||||
GeneratedFileMB int64 `json:"generatedFileMB"`
|
||||
@@ -50,6 +74,11 @@ type RuntimeTaskPolicy struct {
|
||||
VideoTimeoutMinutes int `json:"videoTimeoutMinutes"`
|
||||
StoryboardTimeoutMinutes int `json:"storyboardTimeoutMinutes"`
|
||||
DefaultTimeoutMinutes int `json:"defaultTimeoutMinutes"`
|
||||
// AgentStepMaxOutputTokens 是画布 Agent 单步模型调用的输出上限(思考 + 正文 + 工具调用参数)。
|
||||
// 0 表示不限制输出,此时单步最坏耗时只由 AgentStepTimeoutSeconds 兜底。
|
||||
AgentStepMaxOutputTokens int `json:"agentStepMaxOutputTokens"`
|
||||
// AgentStepTimeoutSeconds 是画布 Agent 单步模型调用的秒级墙钟;0 表示沿用文本任务超时。
|
||||
AgentStepTimeoutSeconds int `json:"agentStepTimeoutSeconds"`
|
||||
}
|
||||
|
||||
type RuntimeRequestPolicy struct {
|
||||
@@ -123,6 +152,8 @@ func DefaultRuntimePolicy() RuntimePolicySetting {
|
||||
VideoTimeoutMinutes: 60,
|
||||
StoryboardTimeoutMinutes: 20,
|
||||
DefaultTimeoutMinutes: 10,
|
||||
AgentStepMaxOutputTokens: agentStepMaxOutputTokensDefault(),
|
||||
AgentStepTimeoutSeconds: agentStepTimeoutSecondsDefault(),
|
||||
},
|
||||
Request: RuntimeRequestPolicy{
|
||||
TaskCreatePerMinute: 30,
|
||||
@@ -162,6 +193,7 @@ func selfUseRuntimePolicy() RuntimePolicySetting {
|
||||
ImageTimeoutMinutes: maxRuntimeTimeoutMinutes, TextTimeoutMinutes: maxRuntimeTimeoutMinutes,
|
||||
AudioTimeoutMinutes: maxRuntimeTimeoutMinutes, VideoTimeoutMinutes: maxRuntimeTimeoutMinutes,
|
||||
StoryboardTimeoutMinutes: maxRuntimeTimeoutMinutes, DefaultTimeoutMinutes: maxRuntimeTimeoutMinutes,
|
||||
AgentStepMaxOutputTokens: MaxRuntimeAgentStepOutputTokens, AgentStepTimeoutSeconds: MaxRuntimeAgentStepTimeoutSeconds,
|
||||
}
|
||||
value.Request = RuntimeRequestPolicy{
|
||||
TaskCreatePerMinute: maxRuntimeRate,
|
||||
@@ -345,6 +377,12 @@ func validateRuntimePolicy(value RuntimePolicySetting) error {
|
||||
return kernel.BadAuthRequest(fmt.Sprintf("%s必须是 1-%d 分钟的整数", label, maxRuntimeTimeoutMinutes))
|
||||
}
|
||||
}
|
||||
if task.AgentStepMaxOutputTokens != 0 && (task.AgentStepMaxOutputTokens < MinRuntimeAgentStepOutputTokens || task.AgentStepMaxOutputTokens > MaxRuntimeAgentStepOutputTokens) {
|
||||
return kernel.BadAuthRequest(fmt.Sprintf("Agent 单步输出上限必须是 0 或 %d-%d 的整数 (0 表示不限制)", MinRuntimeAgentStepOutputTokens, MaxRuntimeAgentStepOutputTokens))
|
||||
}
|
||||
if task.AgentStepTimeoutSeconds != 0 && (task.AgentStepTimeoutSeconds < MinRuntimeAgentStepTimeoutSeconds || task.AgentStepTimeoutSeconds > MaxRuntimeAgentStepTimeoutSeconds) {
|
||||
return kernel.BadAuthRequest(fmt.Sprintf("Agent 单步超时必须是 0 或 %d-%d 秒的整数 (0 表示沿用文本任务超时)", MinRuntimeAgentStepTimeoutSeconds, MaxRuntimeAgentStepTimeoutSeconds))
|
||||
}
|
||||
request := value.Request
|
||||
for label, item := range map[string]int{
|
||||
"任务创建频控": request.TaskCreatePerMinute,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App, Button, Form, InputNumber, Skeleton } from "antd";
|
||||
import { AlertTriangle, Database, Gauge, Infinity as InfinityIcon, Network, RefreshCw, RotateCcw, Save, ShieldCheck, TimerReset } from "lucide-react";
|
||||
import { AlertTriangle, Bot, Database, Gauge, Infinity as InfinityIcon, Network, RefreshCw, RotateCcw, Save, ShieldCheck, TimerReset } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useBlocker } from "react-router";
|
||||
|
||||
@@ -51,6 +51,27 @@ const timeoutFields: PolicyField[] = [
|
||||
{ group: "task", name: "defaultTimeoutMinutes", label: "默认任务超时", extra: "未匹配专用类型时使用的最长执行时间。", unit: "分钟", max: 9_999 },
|
||||
];
|
||||
|
||||
const agentFields: PolicyField[] = [
|
||||
{
|
||||
group: "task",
|
||||
name: "agentStepMaxOutputTokens",
|
||||
label: "单步输出上限",
|
||||
extra: "画布 Agent 每次模型调用的输出上限,含思考、正文与工具调用参数。0 表示不限制,此时只有单步超时兜底;抬高可以避免长思考模型被截断后返回空内容。",
|
||||
unit: "token",
|
||||
min: 0,
|
||||
max: 131_072,
|
||||
},
|
||||
{
|
||||
group: "task",
|
||||
name: "agentStepTimeoutSeconds",
|
||||
label: "单步超时",
|
||||
extra: "画布 Agent 单步模型调用的最长等待时间,到点会中止这一步并自动关思考重试一次。0 表示沿用“文本任务超时”,不再单独计时。",
|
||||
unit: "秒",
|
||||
min: 0,
|
||||
max: 3_600,
|
||||
},
|
||||
];
|
||||
|
||||
const rateFields: PolicyField[] = [
|
||||
{ group: "request", name: "taskCreatePerMinute", label: "任务创建", extra: "每账号每分钟允许创建的任务数。", unit: "次/分钟", max: 999_999 },
|
||||
{ group: "request", name: "resourceUploadPerMinute", label: "资源上传", extra: "每账号每分钟上传资源的次数。", unit: "次/分钟", max: 999_999 },
|
||||
@@ -88,6 +109,15 @@ const policySections: PolicySectionDefinition[] = [
|
||||
status: <AdminStatusBadge label="保存后热更新" tone="info" />,
|
||||
},
|
||||
{ id: "policy-timeout", icon: <TimerReset className="size-4" aria-hidden="true" />, title: "任务超时", shortTitle: "任务超时", description: "不同生成类型的最长执行时间。", fields: timeoutFields },
|
||||
{
|
||||
id: "policy-agent",
|
||||
icon: <Bot className="size-4" aria-hidden="true" />,
|
||||
title: "画布 Agent 单步",
|
||||
shortTitle: "Agent 单步",
|
||||
description: "画布 Agent 每一步模型调用的输出上限与等待时限;两者一起决定单步最坏耗时。",
|
||||
fields: agentFields,
|
||||
status: <AdminStatusBadge label="保存后热更新" tone="info" />,
|
||||
},
|
||||
{ id: "policy-rate", icon: <ShieldCheck className="size-4" aria-hidden="true" />, title: "业务频控", shortTitle: "业务频控", description: "账号与 IP 维度的固定窗口请求限制。", fields: rateFields },
|
||||
{ id: "policy-relay", icon: <Network className="size-4" aria-hidden="true" />, title: "渠道中转与熔断", shortTitle: "中转与熔断", description: "请求体、响应体、并发、超时和上游故障保护。", fields: relayFields },
|
||||
];
|
||||
|
||||
@@ -333,6 +333,10 @@ export type RuntimeTaskPolicy = {
|
||||
videoTimeoutMinutes: number;
|
||||
storyboardTimeoutMinutes: number;
|
||||
defaultTimeoutMinutes: number;
|
||||
/** 画布 Agent 单步模型调用的输出上限(思考 + 正文 + 工具参数);0 表示不限制。 */
|
||||
agentStepMaxOutputTokens: number;
|
||||
/** 画布 Agent 单步模型调用的秒级墙钟;0 表示沿用文本任务超时。 */
|
||||
agentStepTimeoutSeconds: number;
|
||||
};
|
||||
|
||||
export type RuntimeRequestPolicy = {
|
||||
|
||||
Reference in New Issue
Block a user