feat(audit): auto retry missed tasks

Add retry-based auto remediation for audit findings and document new config knobs.
This commit is contained in:
root 2026-02-26 19:14:47 +08:00
parent 2b66547714
commit cca12ab08a
10 changed files with 446 additions and 18 deletions

View file

@ -785,6 +785,41 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable
* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval
### Audit (Task Supervisor)
PicoClaw can periodically audit background tasks (subagents spawned via `spawn/sessions_spawn`) and detect:
- missed tasks (overdue planned/running, failed with retry budget)
- low-quality completions (empty result)
- execution inconsistencies (no tool evidence in strict mode)
You can optionally enable **automatic make-up / retry** by spawning remediation subagents:
- `safe_only` (default): record remediation notes only
- `retry_missed`: auto-retry missed findings
- `retry_all`: auto-retry missed + quality + inconsistency
**Example configuration:**
```json
{
"orchestration": {
"retry_limit_per_task": 10
},
"audit": {
"enabled": true,
"interval_minutes": 5,
"lookback_minutes": 180,
"auto_remediation": "retry_all",
"max_auto_remediations_per_cycle": 3,
"remediation_cooldown_minutes": 10,
"notify_channel": "last_active"
}
}
```
> Tip: Leave `audit.remediation_agent_id` empty to run retries on the same model. Set it if you want retries to run on a dedicated supervisor agent.
### Providers
> [!NOTE]

View file

@ -420,6 +420,41 @@ Agent 读取 HEARTBEAT.md
- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
### 审计 / 监管与自动补做 (Audit)
PicoClaw 支持周期性审计后台任务(主要是通过 `spawn/sessions_spawn` 生成的子任务),用于发现:
- **漏执行 / 超时**planned/running 任务超时、failed 但仍有重试预算
- **低质量输出**completed 但结果为空
- **执行不一致**strict 模式下 completed 但没有工具证据
你也可以开启**自动补做 / 自动重试**(通过再 spawn 一个补做 subagent
- `safe_only`(默认):仅记录提示,不自动重试
- `retry_missed`:自动补做 missed
- `retry_all`:自动补做 missed + quality + inconsistency
**配置示例:**
```json
{
"orchestration": {
"retry_limit_per_task": 10
},
"audit": {
"enabled": true,
"interval_minutes": 5,
"lookback_minutes": 180,
"auto_remediation": "retry_all",
"max_auto_remediations_per_cycle": 3,
"remediation_cooldown_minutes": 10,
"notify_channel": "last_active"
}
}
```
> 小贴士:保持 `audit.remediation_agent_id` 为空即可让补做任务使用同一个模型;如果你想把补做任务交给一个专门的监管 agent再配置该字段即可。
### 提供商 (Providers)
> [!NOTE]

View file

@ -296,6 +296,9 @@
"min_confidence": 0.75,
"inconsistency_policy": "strict",
"auto_remediation": "safe_only",
"max_auto_remediations_per_cycle": 3,
"remediation_cooldown_minutes": 10,
"remediation_agent_id": "",
"notify_channel": "last_active",
"supervisor": {
"enabled": false,

View file

@ -40,6 +40,7 @@ This document describes how PicoClaw's subagent orchestration and periodic audit
- Used as default deadline metadata in task ledger entries.
- `retry_limit_per_task`
- Used by audit logic to detect failed tasks that still have retry budget.
- Also caps how many automatic remediation retries can be spawned for one task.
### `audit`
@ -54,7 +55,16 @@ This document describes how PicoClaw's subagent orchestration and periodic audit
- `inconsistency_policy`
- `strict` mode flags completed tasks with no tool evidence.
- `auto_remediation`
- `safe_only` records low-risk remediation actions in ledger.
- `safe_only` records low-risk remediation actions in ledger (no retries).
- `retry_missed` automatically spawns subagent retries for `missed` findings.
- `retry_all` automatically spawns retries for `missed`, `quality`, and `inconsistency` findings.
- `retry` is accepted as an alias for `retry_missed`.
- `max_auto_remediations_per_cycle`
- Caps how many remediation tasks can be spawned in one audit cycle (prevents runaway loops).
- `remediation_cooldown_minutes`
- Suppresses repeated remediation attempts for the same task within the cooldown window.
- `remediation_agent_id`
- Optional agent id used to execute remediation retries (requires subagent allowlist when targeting a different agent).
- `notify_channel`
- Destination for audit report:
- `last_active`: last recorded user channel/chat.
@ -118,6 +128,45 @@ Optional model checks:
- Supervisor model receives task JSON and returns structured score/issues.
- Findings are merged into deterministic report.
## Auto Remediation (Retry / Make-up)
When `audit.auto_remediation` is set to a retry mode (`retry_missed` / `retry_all`), the audit loop can automatically **spawn a subagent** to retry missed or low-quality tasks.
Behavior:
- The audit loop scans the task ledger for findings.
- For eligible findings, it spawns a new subagent task with stricter acceptance criteria.
- It records a remediation entry (`action=retry`) and increments `retry_count` on the original task.
- It respects:
- `audit.max_auto_remediations_per_cycle` (per-cycle cap),
- `audit.remediation_cooldown_minutes` (per-task cooldown),
- `orchestration.retry_limit_per_task` (per-task retry budget).
Delivery:
- By default, remediation retries run on the default agent using the same provider/model as normal subagent tasks.
- If `audit.remediation_agent_id` is set, the retry is delegated to that agent id (requires subagent allowlist when targeting a different agent).
- The retry result is delivered back to the original task's `origin_channel/origin_chat_id` when available; otherwise it falls back to `audit.notify_channel`.
### Example Configuration
```json
{
"orchestration": {
"retry_limit_per_task": 10
},
"audit": {
"enabled": true,
"interval_minutes": 5,
"lookback_minutes": 180,
"auto_remediation": "retry_all",
"max_auto_remediations_per_cycle": 3,
"remediation_cooldown_minutes": 10,
"notify_channel": "last_active"
}
}
```
## Backward Compatibility
- Existing tools and loop behavior remain unchanged when `audit.enabled=false`.

View file

@ -128,7 +128,7 @@ func (al *AgentLoop) executeAuditCycle(ctx context.Context) {
return
}
al.applyAutoRemediation(report)
al.applyAutoRemediation(ctx, report)
al.publishAuditReport(report)
}
@ -369,8 +369,8 @@ func parseSupervisorReview(raw string) (*supervisorReview, error) {
return &review, nil
}
func (al *AgentLoop) applyAutoRemediation(report *AuditReport) {
if report == nil || len(report.Findings) == 0 || al.taskLedger == nil {
func (al *AgentLoop) applyAutoRemediation(ctx context.Context, report *AuditReport) {
if report == nil || len(report.Findings) == 0 || al.taskLedger == nil || al.cfg == nil {
return
}
@ -378,10 +378,8 @@ func (al *AgentLoop) applyAutoRemediation(report *AuditReport) {
if mode == "" || mode == "disabled" || mode == "off" || mode == "none" {
return
}
if mode != "safe_only" {
return
}
if mode == "safe_only" {
for _, finding := range report.Findings {
if finding.Category != "missed" {
continue
@ -392,6 +390,201 @@ func (al *AgentLoop) applyAutoRemediation(report *AuditReport) {
Note: finding.Message,
})
}
return
}
// retry/auto-fix modes
// - retry_missed: only retry tasks in "missed" category
// - retry_all: retry missed + rerun quality/inconsistency findings
// - retry: alias for retry_missed
switch mode {
case "retry":
mode = "retry_missed"
case "retry_missed", "retry_all":
default:
// Unknown mode: fail closed.
return
}
maxPerCycle := al.cfg.Audit.MaxAutoRemediationsPerCycle
if maxPerCycle <= 0 {
maxPerCycle = 3
}
cooldownMinutes := al.cfg.Audit.RemediationCooldownMinutes
if cooldownMinutes <= 0 {
cooldownMinutes = 10
}
cooldownMS := int64(cooldownMinutes) * 60 * 1000
retryLimit := al.cfg.Orchestration.RetryLimitPerTask
if retryLimit < 0 {
retryLimit = 0
}
targetAgentID := strings.TrimSpace(al.cfg.Audit.RemediationAgentID)
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil || defaultAgent.SubagentManager == nil {
return
}
if targetAgentID != "" {
normalized, ok := al.registry.GetAgent(targetAgentID)
if !ok || normalized == nil {
logger.WarnCF("audit", "Remediation agent not found; falling back to default agent", map[string]any{
"remediation_agent_id": targetAgentID,
})
targetAgentID = ""
} else {
targetAgentID = normalized.ID
}
if targetAgentID != "" && targetAgentID != defaultAgent.ID && !al.registry.CanSpawnSubagent(defaultAgent.ID, targetAgentID) {
logger.WarnCF("audit", "Remediation agent not allowed by subagent allowlist; falling back to default agent", map[string]any{
"parent_agent_id": defaultAgent.ID,
"remediation_agent_id": targetAgentID,
})
targetAgentID = ""
}
}
nowMS := time.Now().UnixMilli()
thresholdMS := nowMS - cooldownMS
spawned := 0
for _, finding := range report.Findings {
if spawned >= maxPerCycle {
return
}
if strings.TrimSpace(finding.TaskID) == "" {
continue
}
// Decide whether this finding should trigger an automatic rerun.
switch finding.Category {
case "missed":
// always eligible in retry modes
case "quality", "inconsistency":
if mode != "retry_all" {
continue
}
default:
continue
}
entry, ok := al.taskLedger.Get(finding.TaskID)
if !ok {
continue
}
if retryLimit > 0 && entry.RetryCount >= retryLimit {
continue
}
intent := strings.TrimSpace(entry.Intent)
if intent == "" {
_ = al.taskLedger.AddRemediation(entry.ID, tools.TaskRemediation{
Action: "retry",
Status: "skipped",
Note: "missing task intent; cannot auto-retry",
})
continue
}
if hasRecentRetryRemediation(entry.Remediations, thresholdMS) {
continue
}
originChannel, originChatID := al.resolveRemediationDestination(entry)
if originChannel == "" || originChatID == "" {
continue
}
retryTask := buildRetryTask(finding, entry)
label := fmt.Sprintf("audit-%s:%s", finding.Category, entry.ID)
taskInfo, err := defaultAgent.SubagentManager.SpawnTask(
ctx,
retryTask,
label,
targetAgentID,
originChannel,
originChatID,
nil,
)
if err != nil {
_ = al.taskLedger.AddRemediation(entry.ID, tools.TaskRemediation{
Action: "retry",
Status: "error",
Note: fmt.Sprintf("failed to spawn retry task: %v", err),
})
continue
}
_ = al.taskLedger.IncrementRetry(entry.ID)
_ = al.taskLedger.AddRemediation(entry.ID, tools.TaskRemediation{
Action: "retry",
Status: "spawned",
Note: fmt.Sprintf("spawned %s (agent_id=%s)", taskInfo.ID, strings.TrimSpace(targetAgentID)),
})
spawned++
}
}
func hasRecentRetryRemediation(remediations []tools.TaskRemediation, thresholdMS int64) bool {
for _, r := range remediations {
if strings.ToLower(strings.TrimSpace(r.Action)) != "retry" {
continue
}
if r.CreatedAtMS < thresholdMS {
continue
}
switch strings.ToLower(strings.TrimSpace(r.Status)) {
case "queued", "spawned", "running", "skipped":
return true
}
}
return false
}
func (al *AgentLoop) resolveRemediationDestination(entry tools.TaskLedgerEntry) (string, string) {
channel := strings.TrimSpace(entry.OriginChannel)
chatID := strings.TrimSpace(entry.OriginChatID)
if channel != "" && chatID != "" && !constants.IsInternalChannel(channel) {
return channel, chatID
}
channel, chatID = al.resolveAuditDestination()
if channel == "" || chatID == "" || constants.IsInternalChannel(channel) {
return "", ""
}
return channel, chatID
}
func buildRetryTask(finding AuditFinding, entry tools.TaskLedgerEntry) string {
intent := strings.TrimSpace(entry.Intent)
if intent == "" {
return ""
}
reason := strings.TrimSpace(finding.Message)
if reason == "" {
reason = "Task requires follow-up."
}
var b strings.Builder
b.WriteString("You are running an automatic remediation retry for a previously problematic task.\n")
b.WriteString("Be concise and deliver a complete result.\n\n")
b.WriteString(fmt.Sprintf("Original task id: %s\n", entry.ID))
b.WriteString(fmt.Sprintf("Original status: %s\n", entry.Status))
b.WriteString(fmt.Sprintf("Finding category: %s\n", finding.Category))
b.WriteString(fmt.Sprintf("Reason: %s\n\n", reason))
b.WriteString("Task:\n")
b.WriteString(intent)
b.WriteString("\n\nAcceptance criteria:\n")
switch finding.Category {
case "quality":
b.WriteString("- Produce a non-empty result.\n- Include concrete deliverables.\n")
case "inconsistency":
b.WriteString("- If tools are required, use them and complete the task end-to-end.\n")
default:
b.WriteString("- Complete the task end-to-end.\n")
}
return b.String()
}
func (al *AgentLoop) publishAuditReport(report *AuditReport) {

View file

@ -87,3 +87,102 @@ func TestParseSupervisorReview_EmbeddedJSON(t *testing.T) {
t.Fatalf("issue category = %q", review.Issues[0].Category)
}
}
func TestApplyAutoRemediation_RetriesMissedTasksWithCooldown(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = t.TempDir()
cfg.Audit.Enabled = true
cfg.Audit.AutoRemediation = "retry_missed"
cfg.Audit.MaxAutoRemediationsPerCycle = 5
cfg.Audit.RemediationCooldownMinutes = 60
loop := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
ledger := loop.GetTaskLedger()
if ledger == nil {
t.Fatal("expected task ledger")
}
taskID := "task-failed-1"
_ = ledger.UpsertTask(tools.TaskLedgerEntry{
ID: taskID,
Status: tools.TaskStatusFailed,
Intent: "Write a short status update about the project.",
OriginChannel: "telegram",
OriginChatID: "chat-1",
CreatedAtMS: time.Now().Add(-10 * time.Minute).UnixMilli(),
})
report := &AuditReport{
GeneratedAt: time.Now(),
Lookback: 180 * time.Minute,
TotalTasks: 1,
Findings: []AuditFinding{
{
TaskID: taskID,
Category: "missed",
Severity: "medium",
Message: "Task failed and still has retry budget.",
Recommendation: "Retry this task automatically or manually.",
},
},
}
loop.applyAutoRemediation(context.Background(), report)
entry, ok := ledger.Get(taskID)
if !ok {
t.Fatal("expected task in ledger")
}
if entry.RetryCount != 1 {
t.Fatalf("RetryCount = %d, want %d", entry.RetryCount, 1)
}
hasSpawned := false
for _, r := range entry.Remediations {
if strings.EqualFold(r.Action, "retry") && strings.EqualFold(r.Status, "spawned") {
hasSpawned = true
break
}
}
if !hasSpawned {
t.Fatalf("expected spawned retry remediation, got: %+v", entry.Remediations)
}
// Second run should respect cooldown and not spawn another retry.
loop.applyAutoRemediation(context.Background(), report)
entry2, _ := ledger.Get(taskID)
if entry2.RetryCount != 1 {
t.Fatalf("RetryCount after cooldown check = %d, want %d", entry2.RetryCount, 1)
}
spawnedCount := 0
spawnedTaskID := ""
for _, r := range entry2.Remediations {
if strings.EqualFold(r.Action, "retry") && strings.EqualFold(r.Status, "spawned") {
spawnedCount++
parts := strings.Fields(r.Note)
if len(parts) >= 2 {
spawnedTaskID = parts[1]
}
}
}
if spawnedCount != 1 {
t.Fatalf("spawned remediation count = %d, want %d", spawnedCount, 1)
}
// Ensure the spawned subagent finishes before TempDir cleanup.
if spawnedTaskID == "" {
t.Fatal("expected spawned task id in remediation note")
}
deadline := time.Now().Add(2 * time.Second)
for {
subTask, ok := ledger.Get(spawnedTaskID)
if ok && (subTask.Status == tools.TaskStatusCompleted ||
subTask.Status == tools.TaskStatusFailed ||
subTask.Status == tools.TaskStatusCancelled) {
break
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for spawned task %s to finish", spawnedTaskID)
}
time.Sleep(10 * time.Millisecond)
}
}

View file

@ -28,6 +28,7 @@ type AgentInstance struct {
Sessions *session.SessionManager
ContextBuilder *ContextBuilder
Tools *tools.ToolRegistry
SubagentManager *tools.SubagentManager
Subagents *config.SubagentsConfig
SkillsFilter []string
Candidates []providers.FallbackCandidate

View file

@ -174,6 +174,7 @@ func registerSharedTools(
cfg.Orchestration.ToolParallelOverrides,
)
subagentManager.SetTools(agent.Tools)
agent.SubagentManager = subagentManager
currentAgentID := agentID
subagentManager.SetExecutionResolver(func(targetAgentID string) (tools.SubagentExecutionConfig, error) {
return resolveSubagentExecution(cfg, registry, provider, currentAgentID, targetAgentID)

View file

@ -377,6 +377,15 @@ type AuditConfig struct {
MinConfidence float64 `json:"min_confidence" env:"PICOCLAW_AUDIT_MIN_CONFIDENCE"`
InconsistencyPolicy string `json:"inconsistency_policy" env:"PICOCLAW_AUDIT_INCONSISTENCY_POLICY"`
AutoRemediation string `json:"auto_remediation" env:"PICOCLAW_AUDIT_AUTO_REMEDIATION"`
// MaxAutoRemediationsPerCycle limits how many retry/fix tasks can be spawned
// in one audit cycle to avoid runaway loops.
MaxAutoRemediationsPerCycle int `json:"max_auto_remediations_per_cycle" env:"PICOCLAW_AUDIT_MAX_AUTO_REMEDIATIONS_PER_CYCLE"`
// RemediationCooldownMinutes prevents re-triggering remediation for the same
// task too frequently.
RemediationCooldownMinutes int `json:"remediation_cooldown_minutes" env:"PICOCLAW_AUDIT_REMEDIATION_COOLDOWN_MINUTES"`
// RemediationAgentID optionally delegates remediation retries to a specific
// agent id (requires subagent allowlist when targeting a different agent).
RemediationAgentID string `json:"remediation_agent_id" env:"PICOCLAW_AUDIT_REMEDIATION_AGENT_ID"`
NotifyChannel string `json:"notify_channel" env:"PICOCLAW_AUDIT_NOTIFY_CHANNEL"`
}

View file

@ -370,6 +370,9 @@ func DefaultConfig() *Config {
MinConfidence: 0.75,
InconsistencyPolicy: "strict",
AutoRemediation: "safe_only",
MaxAutoRemediationsPerCycle: 3,
RemediationCooldownMinutes: 10,
RemediationAgentID: "",
NotifyChannel: "last_active",
Supervisor: AuditSupervisorConfig{
Enabled: false,