diff --git a/backend/internal/app/cloud_agent_pi_coordinator.go b/backend/internal/app/cloud_agent_pi_coordinator.go index cfd5723cd..e0980299a 100644 --- a/backend/internal/app/cloud_agent_pi_coordinator.go +++ b/backend/internal/app/cloud_agent_pi_coordinator.go @@ -356,18 +356,21 @@ func (s *Service) runCloudAgentPiSession(ctx context.Context, userID, runID stri }, }) if err != nil { - // 已经因审批暂停的会话,收尾阶段再报错不影响运行:状态停在等审批, - // 由审批结论接管。把它当失败处理会让用户还没来得及审批就整轮中断。 - if pausedForApproval.Load() && !errors.Is(err, context.Canceled) { - log.Printf("[Agent] session error after approval pause ignored run=%s: %v", runID, err) + // The runtime can abort a prompt before the in-process pause flag is set + // (for example when the SDK races into a second model request). The durable + // run state is authoritative: an approval/user wait owns the next action + // and must never be converted into a terminal runtime failure. + paused := pausedForApproval.Load() || s.cloudAgentRunPausedForInput(userID, runID) + if paused { + log.Printf("[Agent] session error after durable pause ignored run=%s: %v", runID, err) return nil } return err } - // 本轮因审批暂停:运行由审批决定接管,不能在这里完成。用户可能在会话退出前 - // 就已批准,此时状态已回到 running,若继续会把未恢复的运行误标为完成。 - if pausedForApproval.Load() { + // The same durable check covers a clean runtime exit after an asynchronous + // abort, when the local atomic flag was not observed by this coordinator. + if pausedForApproval.Load() || s.cloudAgentRunPausedForInput(userID, runID) { return nil } @@ -382,6 +385,17 @@ func (s *Service) runCloudAgentPiSession(ctx context.Context, userID, runID stri return s.completeCloudAgentPiRun(userID, runID) } +func (s *Service) cloudAgentRunPausedForInput(userID, runID string) bool { + if s == nil || s.repo == nil || userID == "" || runID == "" { + return false + } + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return false + } + return run.Status == "waiting_approval" || run.Status == "waiting_user" +} + // cloudAgentPiTurnSettled 判断本轮是否已在会话里完整结束:本轮已有成功的助手回复, // 没有挂着的任务或审批,且会话最后一条消息是不带工具调用的正常结束回复。 // 只能用于本轮自己落库的快照:运行时的事件按顺序提交,助手回复计数增长之前, diff --git a/backend/internal/app/image_parameters.go b/backend/internal/app/image_parameters.go index 8b4c14fec..7d3e603c6 100644 --- a/backend/internal/app/image_parameters.go +++ b/backend/internal/app/image_parameters.go @@ -262,6 +262,14 @@ func imageParameterValue(p protocol.ImageParameter, value any) any { return value } +func imageParameterAdmissionError(field, issue, message string) error { + return NewModelError(ErrCodeModelCapabilityNotSupported, message).WithDetails(map[string]any{ + "field": field, + "issue": issue, + "message": message, + }) +} + func resolveImageParameterValues(parameters []protocol.ImageParameter, values map[string]any) (map[string]any, error) { allowed := map[string]protocol.ImageParameter{} result := imageParameterDefaults(parameters) @@ -273,11 +281,11 @@ func resolveImageParameterValues(parameters []protocol.ImageParameter, values ma for name, value := range values { p, ok := allowed[name] if !ok { - return nil, ModelCapabilityNotSupported("当前图片模型未启用参数:" + name) + return nil, imageParameterAdmissionError(name, "unsupported_value", "当前图片模型未启用参数:"+name) } value = imageParameterValue(p, value) if !p.Accepts(value) { - return nil, ModelCapabilityNotSupported("图片参数超出支持范围:" + p.Label) + return nil, imageParameterAdmissionError(p.Name, "unsupported_value", "图片参数超出支持范围:"+p.Label) } if value != nil && value != "" { result[name] = value @@ -285,7 +293,7 @@ func resolveImageParameterValues(parameters []protocol.ImageParameter, values ma } for _, p := range parameters { if p.IsEnabled() && p.Required && (result[p.Name] == nil || result[p.Name] == "") { - return nil, ModelCapabilityNotSupported("请填写图片参数:" + p.Label) + return nil, imageParameterAdmissionError(p.Name, "required", "请填写图片参数:"+p.Label) } } return result, nil diff --git a/backend/internal/app/model_capability_argument.go b/backend/internal/app/model_capability_argument.go index a57f157fa..0174729df 100644 --- a/backend/internal/app/model_capability_argument.go +++ b/backend/internal/app/model_capability_argument.go @@ -28,7 +28,7 @@ func newModelCapabilityArgumentError(field, issue string, cause error) error { func modelCapabilityMismatchError(match CapabilityMatch) error { details := map[string]any{"capabilityIssues": match.Issues} for _, issue := range match.Issues { - if issue.Kind != "option" { + if issue.Kind != "option" && issue.Kind != "image_parameter" { continue } field := issue.Name diff --git a/backend/internal/app/model_router_capability.go b/backend/internal/app/model_router_capability.go index 3c3d61186..b56159b5c 100644 --- a/backend/internal/app/model_router_capability.go +++ b/backend/internal/app/model_router_capability.go @@ -8,6 +8,7 @@ package app import ( "encoding/json" + "errors" "fmt" "math" "strconv" @@ -325,7 +326,21 @@ func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityM } } if _, err := resolveImageParameterValues(parameters, values); err != nil { - addIssue("image_parameter", "imageParameters", err.Error()) + var modelErr *ModelError + if errors.As(err, &modelErr) && modelErr != nil { + field, _ := modelErr.Details["field"].(string) + message, _ := modelErr.Details["message"].(string) + if field != "" { + if message == "" { + message = modelErr.Error() + } + addIssue("image_parameter", field, message) + } else { + addIssue("image_parameter", "imageParameters", err.Error()) + } + } else { + addIssue("image_parameter", "imageParameters", err.Error()) + } } } if normalizeCapability(intent.Capability) != normalizeCapability(spec.Capability) {