From a0ec80240312dc3d71fac99c37891af4f667aa8e Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 30 Mar 2026 18:28:38 -0400 Subject: [PATCH] feat: add planning/decomposition phase to KimiClaw heartbeat Complex tasks (body >500 chars) now get a 2-minute planning pass first: - Kimi analyzes the task and decides EXECUTE (single pass) or DECOMPOSE - DECOMPOSE: creates child issues labeled assigned-kimi, marks parent done - EXECUTE: proceeds to 8-minute execution with --timeout 480 - Simple tasks skip planning and execute directly Also: - Pass --timeout to openclaw agent (was using default 600s, now explicit) - Post KimiClaw results back as comments on the issue - Post failure comments with actionable advice - Execution prompt tells Kimi to stop and summarize if running long --- uniwizard/kimi-heartbeat.sh | 214 ++++++++++++++++++++++++++++-------- 1 file changed, 171 insertions(+), 43 deletions(-) diff --git a/uniwizard/kimi-heartbeat.sh b/uniwizard/kimi-heartbeat.sh index ade9b2d..4b69296 100755 --- a/uniwizard/kimi-heartbeat.sh +++ b/uniwizard/kimi-heartbeat.sh @@ -10,6 +10,11 @@ # 2. Add the "assigned-kimi" label # 3. This script picks it up, dispatches to KimiClaw, posts results back # 4. Label transitions: assigned-kimi → kimi-in-progress → kimi-done +# +# PLANNING: If the issue body is >500 chars or contains "##" headers, +# KimiClaw first runs a 2-minute planning pass to decompose the task. +# If it needs subtasks, it creates child issues and labels them assigned-kimi +# for the next heartbeat cycle. This prevents 10-minute timeouts on complex work. set -euo pipefail @@ -25,6 +30,9 @@ fi LOG="/tmp/kimi-heartbeat.log" LOCKFILE="/tmp/kimi-heartbeat.lock" MAX_DISPATCH=5 # Don't overwhelm Kimi with too many parallel tasks +PLAN_TIMEOUT=120 # 2 minutes for planning pass +EXEC_TIMEOUT=480 # 8 minutes for execution pass +BODY_COMPLEXITY_THRESHOLD=500 # chars — above this triggers planning REPOS=( "Timmy_Foundation/timmy-home" @@ -68,17 +76,19 @@ for i in data: labels = [l['name'] for l in i.get('labels', [])] if 'kimi-in-progress' in labels or 'kimi-done' in labels: continue - # Pipe-delimited: number|title|body (truncated, newlines removed) - body = (i.get('body', '') or '')[:1500].replace('\n', ' ').replace('|', ' ') + # Pipe-delimited: number|title|body_length|body (truncated, newlines removed) + body = (i.get('body', '') or '') + body_len = len(body) + body_clean = body[:1500].replace('\n', ' ').replace('|', ' ') title = i['title'].replace('|', ' ') - print(f\"{i['number']}|{title}|{body}\") + print(f\"{i['number']}|{title}|{body_len}|{body_clean}\") " 2>/dev/null) [ -z "$issues" ] && continue - while IFS='|' read -r issue_num title body; do + while IFS='|' read -r issue_num title body_len body; do [ -z "$issue_num" ] && continue - log "FOUND: $repo #$issue_num — $title" + log "FOUND: $repo #$issue_num — $title (body: ${body_len} chars)" # --- Get label IDs for this repo --- label_json=$(curl -sf -H "Authorization: token $TIMMY_TOKEN" \ @@ -86,6 +96,7 @@ for i in data: progress_id=$(echo "$label_json" | python3 -c "import json,sys; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='kimi-in-progress']" 2>/dev/null) done_id=$(echo "$label_json" | python3 -c "import json,sys; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='kimi-done']" 2>/dev/null) + kimi_id=$(echo "$label_json" | python3 -c "import json,sys; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='assigned-kimi']" 2>/dev/null) # --- Add kimi-in-progress label --- if [ -n "$progress_id" ]; then @@ -94,16 +105,120 @@ for i in data: "$BASE/repos/$repo/issues/$issue_num/labels" > /dev/null 2>&1 || true fi - # --- Post pickup comment as KimiClaw --- - curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d "{\"body\":\"🟠 **KimiClaw picking up this task** via heartbeat.\\nBackend: kimi/kimi-code (Moonshot AI)\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\"}" \ - "$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true + # --- Decide: plan first or execute directly --- + needs_planning=false + if [ "$body_len" -gt "$BODY_COMPLEXITY_THRESHOLD" ]; then + needs_planning=true + fi - log "DISPATCH: $repo #$issue_num to openclaw" + if [ "$needs_planning" = true ]; then + # ============================================= + # PHASE 1: PLANNING PASS (2 min timeout) + # ============================================= + log "PLAN: $repo #$issue_num — complex task, running planning pass" - # --- Build the prompt --- - prompt="You are KimiClaw, an AI agent powered by Kimi K2.5 (Moonshot AI). + curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"body\":\"🟠 **KimiClaw picking up this task** via heartbeat.\\nBackend: kimi/kimi-code (Moonshot AI)\\nMode: **Planning first** (task is complex)\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\"}" \ + "$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true + + plan_prompt="You are KimiClaw, a planning agent. You have 2 MINUTES. + +TASK: Analyze this Gitea issue and decide if you can complete it in under 8 minutes, or if it needs to be broken into subtasks. + +ISSUE #$issue_num in $repo: $title + +BODY: +$body + +RULES: +- If you CAN complete this in one pass (research, write analysis, answer a question): respond with EXECUTE followed by a one-line plan. +- If the task is TOO BIG (needs git operations, multiple repos, >2000 words of output, or multi-step implementation): respond with DECOMPOSE followed by a numbered list of 2-5 smaller subtasks. Each subtask must be completable in under 8 minutes by itself. +- Each subtask line format: SUBTASK: | <one-line description> +- Be realistic about what fits in 8 minutes with no terminal access. +- You CANNOT clone repos, run git, or execute code. You CAN research, analyze, write specs, review code via API, and produce documents. + +Respond with ONLY your decision. No preamble." + + plan_result=$(openclaw agent --agent main --message "$plan_prompt" --timeout $PLAN_TIMEOUT --json 2>/dev/null || echo '{"status":"error"}') + plan_status=$(echo "$plan_result" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status','error'))" 2>/dev/null || echo "error") + plan_text=$(echo "$plan_result" | python3 -c " +import json,sys +d = json.load(sys.stdin) +payloads = d.get('result',{}).get('payloads',[]) +print(payloads[0]['text'] if payloads else '') +" 2>/dev/null || echo "") + + if echo "$plan_text" | grep -qi "^DECOMPOSE"; then + # --- Create subtask issues --- + log "DECOMPOSE: $repo #$issue_num — creating subtasks" + + # Post the plan as a comment + escaped_plan=$(echo "$plan_text" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null) + curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"body\":\"📋 **Planning complete — decomposing into subtasks:**\\n\\n$plan_text\"}" \ + "$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true + + # Extract SUBTASK lines and create child issues + echo "$plan_text" | grep -i "^SUBTASK:" | head -5 | while IFS='|' read -r sub_title sub_desc; do + sub_title=$(echo "$sub_title" | sed 's/^SUBTASK: *//') + sub_desc=$(echo "${sub_desc:-$sub_title}" | sed 's/^ *//') + + if [ -n "$sub_title" ]; then + sub_body="## Parent Issue\\nChild of #$issue_num: $title\\n\\n## Task\\n$sub_desc\\n\\n## Constraints\\n- Must complete in under 8 minutes\\n- No git/terminal operations\\n- Post results as analysis/documentation\\n\\n## Assignee\\n@KimiClaw" + + curl -sf -X POST -H "Authorization: token $TIMMY_TOKEN" -H "Content-Type: application/json" \ + -d "{\"title\":\"[SUB] $sub_title\",\"body\":\"$sub_body\"}" \ + "$BASE/repos/$repo/issues" > /dev/null 2>&1 + + # Get the issue number of what we just created and label it + new_num=$(curl -sf -H "Authorization: token $TIMMY_TOKEN" \ + "$BASE/repos/$repo/issues?state=open&limit=1&type=issues" | \ + python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['number'] if d else '')" 2>/dev/null) + + if [ -n "$new_num" ] && [ -n "$kimi_id" ]; then + curl -sf -X POST -H "Authorization: token $TIMMY_TOKEN" -H "Content-Type: application/json" \ + -d "{\"labels\":[$kimi_id]}" \ + "$BASE/repos/$repo/issues/$new_num/labels" > /dev/null 2>&1 || true + log "SUBTASK: $repo #$new_num — $sub_title" + fi + fi + done + + # Mark parent as kimi-done (subtasks will be picked up next cycle) + [ -n "$progress_id" ] && curl -sf -X DELETE -H "Authorization: token $TIMMY_TOKEN" \ + "$BASE/repos/$repo/issues/$issue_num/labels/$progress_id" > /dev/null 2>&1 || true + [ -n "$done_id" ] && curl -sf -X POST -H "Authorization: token $TIMMY_TOKEN" -H "Content-Type: application/json" \ + -d "{\"labels\":[$done_id]}" \ + "$BASE/repos/$repo/issues/$issue_num/labels" > /dev/null 2>&1 || true + + dispatched=$((dispatched + 1)) + log "PLANNED: $repo #$issue_num — subtasks created, parent marked done" + + else + # --- Plan says EXECUTE — proceed to execution --- + log "EXECUTE: $repo #$issue_num — planning pass says single-pass OK" + # Fall through to execution below + needs_planning=false + fi + fi + + if [ "$needs_planning" = false ]; then + # ============================================= + # PHASE 2: EXECUTION PASS (8 min timeout) + # ============================================= + + # Post pickup comment if we didn't already (simple tasks skip planning) + if [ "$body_len" -le "$BODY_COMPLEXITY_THRESHOLD" ]; then + curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"body\":\"🟠 **KimiClaw picking up this task** via heartbeat.\\nBackend: kimi/kimi-code (Moonshot AI)\\nMode: **Direct execution** (task fits in one pass)\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\"}" \ + "$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true + fi + + log "DISPATCH: $repo #$issue_num to openclaw (timeout: ${EXEC_TIMEOUT}s)" + + exec_prompt="You are KimiClaw, an AI agent powered by Kimi K2.5 (Moonshot AI). You are working on Gitea issue #$issue_num in repo $repo. +You have 8 MINUTES maximum. Be concise and focused. ISSUE TITLE: $title @@ -112,41 +227,54 @@ $body YOUR TASK: 1. Read the issue carefully and do the work described -2. Be thorough — use your full 256K context when needed -3. When done, provide your COMPLETE results as your response (use markdown) -4. Include code, analysis, or deliverables directly in your response -5. If the task requires creating files, describe what to create and provide the full content" +2. Stay focused — deliver the core ask, skip nice-to-haves +3. Provide your COMPLETE results as your response (use markdown) +4. If you realize mid-task this will take longer than 8 minutes, STOP and summarize what you've done so far plus what remains" - # --- Dispatch to OpenClaw (background) --- - ( - result=$(openclaw agent --agent main --message "$prompt" --json 2>/dev/null || echo '{"status":"error"}') - status=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status','error'))" 2>/dev/null || echo "error") - - if [ "$status" = "ok" ]; then - log "COMPLETED: $repo #$issue_num" - - # Remove kimi-in-progress, add kimi-done - [ -n "$progress_id" ] && curl -sf -X DELETE -H "Authorization: token $TIMMY_TOKEN" \ - "$BASE/repos/$repo/issues/$issue_num/labels/$progress_id" > /dev/null 2>&1 || true - [ -n "$done_id" ] && curl -sf -X POST -H "Authorization: token $TIMMY_TOKEN" -H "Content-Type: application/json" \ - -d "{\"labels\":[$done_id]}" \ - "$BASE/repos/$repo/issues/$issue_num/labels" > /dev/null 2>&1 || true - else - log "FAILED: $repo #$issue_num — status=$status" + # --- Dispatch to OpenClaw (background) --- + ( + result=$(openclaw agent --agent main --message "$exec_prompt" --timeout $EXEC_TIMEOUT --json 2>/dev/null || echo '{"status":"error"}') + status=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status','error'))" 2>/dev/null || echo "error") - # Post failure comment - curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d "{\"body\":\"🔴 **KimiClaw failed on this task.**\\nStatus: $status\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\\n\\nWill retry on next heartbeat if label is reset to assigned-kimi.\"}" \ - "$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true + # Extract response text + response_text=$(echo "$result" | python3 -c " +import json,sys +d = json.load(sys.stdin) +payloads = d.get('result',{}).get('payloads',[]) +print(payloads[0]['text'][:3000] if payloads else 'No response') +" 2>/dev/null || echo "No response") - # Remove kimi-in-progress on failure so it can be retried - [ -n "$progress_id" ] && curl -sf -X DELETE -H "Authorization: token $TIMMY_TOKEN" \ - "$BASE/repos/$repo/issues/$issue_num/labels/$progress_id" > /dev/null 2>&1 || true - fi - ) & + if [ "$status" = "ok" ] && [ "$response_text" != "No response" ]; then + log "COMPLETED: $repo #$issue_num" - dispatched=$((dispatched + 1)) - log "DISPATCHED: $repo #$issue_num (background PID $!)" + # Post result as comment (escape for JSON) + escaped=$(echo "$response_text" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read())[1:-1])" 2>/dev/null) + curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"body\":\"✅ **KimiClaw result:**\\n\\n$escaped\"}" \ + "$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true + + # Remove kimi-in-progress, add kimi-done + [ -n "$progress_id" ] && curl -sf -X DELETE -H "Authorization: token $TIMMY_TOKEN" \ + "$BASE/repos/$repo/issues/$issue_num/labels/$progress_id" > /dev/null 2>&1 || true + [ -n "$done_id" ] && curl -sf -X POST -H "Authorization: token $TIMMY_TOKEN" -H "Content-Type: application/json" \ + -d "{\"labels\":[$done_id]}" \ + "$BASE/repos/$repo/issues/$issue_num/labels" > /dev/null 2>&1 || true + else + log "FAILED: $repo #$issue_num — status=$status" + + curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"body\":\"🔴 **KimiClaw failed/timed out.**\\nStatus: $status\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\\n\\nTask may be too complex for single-pass execution. Consider breaking into smaller subtasks.\"}" \ + "$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true + + # Remove kimi-in-progress on failure + [ -n "$progress_id" ] && curl -sf -X DELETE -H "Authorization: token $TIMMY_TOKEN" \ + "$BASE/repos/$repo/issues/$issue_num/labels/$progress_id" > /dev/null 2>&1 || true + fi + ) & + + dispatched=$((dispatched + 1)) + log "DISPATCHED: $repo #$issue_num (background PID $!)" + fi # Enforce dispatch cap if [ "$dispatched" -ge "$MAX_DISPATCH" ]; then