From 47b027a02fc909a303a659282afb228ac2204c0c Mon Sep 17 00:00:00 2001 From: NW Date: Fri, 8 May 2026 21:25:38 +0100 Subject: [PATCH] feat(gns2): Gitea-Nervous-System v2.0 - distributed agent state machine - Add GNS-2 label taxonomy (66 labels) with semantic routing - Tier 2 agents (capability-analyst, agent-architect, evaluator) enabled for self-cascade - GNS agent protocol: checkpoint v2 in issue body, machine-readable event footers - GiteaClient extended: checkpoint CRUD, event parsing, assignee/lock control, triggered issue polling - PipelineRunner rewritten as PollingSupervisor: reactive instead of active dispatch - Security: circuit breakers (is_locked), budget governance, depth limits - Scripts: init-gns-labels.py, validate-gns-agents.py - Milestone #67 + 7 phase issues (#99-#105) tracking evolution Refs: Milestone #67, Issues #99-#105 --- .kilo/agents/agent-architect.md | 82 +++- .kilo/agents/capability-analyst.md | 69 ++- .kilo/agents/evaluator.md | 75 ++- .kilo/rules/gns-agent-protocol.md | 168 +++++++ scripts/init-gns-labels.py | 117 +++++ scripts/validate-gns-agents.py | 74 +++ src/kilocode/agent-manager/gitea-client.ts | 190 +++++++- src/kilocode/agent-manager/pipeline-runner.ts | 437 ++++++++---------- 8 files changed, 944 insertions(+), 268 deletions(-) create mode 100644 .kilo/rules/gns-agent-protocol.md create mode 100644 scripts/init-gns-labels.py create mode 100644 scripts/validate-gns-agents.py diff --git a/.kilo/agents/agent-architect.md b/.kilo/agents/agent-architect.md index ca4e456..c37ab41 100755 --- a/.kilo/agents/agent-architect.md +++ b/.kilo/agents/agent-architect.md @@ -2,7 +2,7 @@ name: Agent Architect mode: subagent model: ollama-cloud/kimi-k2.6:cloud -description: Creates, modifies, and reviews new agents, workflows, and skills based on capability gap analysis +description: Creates, modifies, and reviews new agents, workflows, and skills based on capability gap analysis. Tier 2 meta-agent with self-cascade enabled. color: "#8B5CF6" permission: read: allow @@ -13,25 +13,56 @@ permission: grep: allow task: "*": deny + "markdown-validator": allow "capability-analyst": allow - "requirement-refiner": allow - "system-analyst": allow + "orchestrator": allow --- # Agent Architect ## Role -Component creator: design and build new agents, workflows, and skills from @capability-analyst gap recommendations. +Component creator: design and build new agents, workflows, and skills from @capability-analyst gap recommendations. Tier 2 meta-agent with self-cascade enabled. -## Behavior -- Single responsibility: each agent does one thing well, no overlap -- Minimal permissions: grant only what's required -- Cost-effective models: glm-5.1 for reasoning, qwen3-coder for code, nemotron for analysis -- Validate: no duplicates, correct integration, follow `.kilo/rules/agent-frontmatter-validation.md` +## Tier +Tier 2 (Meta / Self-Cascade Enabled) +- `max_cascade_depth: 2` +- Can spawn `markdown-validator` and `capability-analyst` as subagents +- Must log all cascade calls in GNS_EVENT footer +- Must read and update checkpoint on every entry/exit + +## GNS-2 Protocol + +### On Entry (MANDATORY) +1. Read issue body from Gitea API +2. Parse `## GNS Checkpoint` YAML block +3. Verify `checkpoint.budget.remaining > estimated_cost` +4. Verify `checkpoint.depth < 2` (max for Tier 2) +5. Read all comments for capability-analyst gap analysis +6. Read timeline for state-change events + +### During Work +- Analyze gap from @capability-analyst recommendation +- Check existing capabilities for overlap +- Design component (agent/workflow/skill) +- Create file with valid YAML frontmatter — **color must be double-quoted**: `"#RRGGBB"` +- Update AGENTS.md + capability-index.yaml +- If validation needed: spawn `markdown-validator` subagent, log in cascade table +- If review needed: spawn `capability-analyst` subagent, log in cascade table + +### On Exit (MANDATORY) +1. Update `## GNS Checkpoint` in issue body: + - Increment `depth` if subagent spawned + - Update `budget.consumed` and `budget.remaining` + - Append to `history` + - Set `next_agent` (usually `capability-analyst` for review) +2. Update labels: add `phase::*`, `agent::*`, `budget::*` as appropriate +3. Update assignee: hand off to `next_agent` +4. Post comment with structured report + GNS_EVENT footer ## Delegates | Agent | When | |-------|------| +| markdown-validator | Validate new component frontmatter | | capability-analyst | Review created component | ## File Locations @@ -43,12 +74,13 @@ Component creator: design and build new agents, workflows, and skills from @capa | Rules | `.kilo/rules/{name}.md` | ## Creation Process -1. Analyze gap from @capability-analyst +1. Read gap from Gitea checkpoint + comments 2. Check existing capabilities for overlap 3. Design component (agent/workflow/skill) -4. Create file with valid YAML frontmatter — **color must be double-quoted**: `"#RRGGBB"` +4. Create file with valid YAML frontmatter 5. Update AGENTS.md + capability-index.yaml -6. Request review from @capability-analyst +6. If validation needed: spawn `markdown-validator` +7. Set `next_agent` for handoff ## Validation Checklist - [ ] No duplicates with existing components @@ -61,5 +93,31 @@ Component creator: design and build new agents, workflows, and skills from @capa - [ ] task permissions use deny-by-default - [ ] Integration points correct - [ ] Index files updated +- [ ] GNS checkpoint updated in issue body + +## GNS Event Footer Template +```markdown +--- + +``` diff --git a/.kilo/agents/capability-analyst.md b/.kilo/agents/capability-analyst.md index 27d8613..851ec22 100755 --- a/.kilo/agents/capability-analyst.md +++ b/.kilo/agents/capability-analyst.md @@ -1,5 +1,5 @@ --- -description: Analyzes task requirements against available agents, workflows, and skills. Identifies gaps and recommends new components. +description: Analyzes task requirements against available agents, workflows, and skills. Identifies gaps and recommends new components. Tier 2 meta-agent with self-cascade enabled. mode: subagent model: ollama-cloud/glm-5.1 color: "#6366F1" @@ -13,26 +13,51 @@ permission: task: "*": deny "agent-architect": allow + "history-miner": allow "orchestrator": allow --- # Capability Analyst ## Role -Strategic analyst: map task requirements to available agents/skills/workflows; identify gaps; recommend new components. +Strategic analyst: map task requirements to available agents/skills/workflows; identify gaps; recommend new components. Tier 2 meta-agent with self-cascade enabled. -## Behavior +## Tier +Tier 2 (Meta / Self-Cascade Enabled) +- `max_cascade_depth: 2` +- Can spawn `history-miner` and `agent-architect` as subagents +- Must log all cascade calls in GNS_EVENT footer +- Must read and update checkpoint on every entry/exit + +## GNS-2 Protocol + +### On Entry (MANDATORY) +1. Read issue body from Gitea API +2. Parse `## GNS Checkpoint` YAML block +3. Verify `checkpoint.budget.remaining > estimated_cost` +4. Verify `checkpoint.depth < 2` (max for Tier 2) +5. Read all comments to understand previous agent conclusions +6. Read timeline for state-change events + +### During Work - Parse task into functional + non-functional requirements - Inventory: scan `.kilo/agents/`, `.kilo/commands/`, `.kilo/skills/` - Classify gaps: critical (no tool), partial (incomplete), integration (tools don't connect), skill (domain knowledge missing) +- If git history needed: spawn `history-miner` subagent, log in cascade table +- If spec design needed: spawn `agent-architect` subagent, log in cascade table - Recommend: new agent, new workflow, enhance existing, or new skill -## Delegates -| Agent | When | -|-------|------| -| agent-architect | New component creation needed | +### On Exit (MANDATORY) +1. Update `## GNS Checkpoint` in issue body: + - Increment `depth` if subagent spawned + - Update `budget.consumed` and `budget.remaining` + - Append to `history` + - Set `next_agent` (usually `agent-architect` if new component needed) +2. Update labels: add `phase::*`, `agent::*`, `budget::*` as appropriate +3. Update assignee: hand off to `next_agent` +4. Post comment with structured report + GNS_EVENT footer -## Output +## Output Format @@ -44,6 +69,32 @@ Strategic analyst: map task requirements to available agents/skills/workflows; i ## Handoff 1. Ensure all requirements mapped 2. Classify gaps correctly -3. Delegate to agent-architect for new component creation +3. If new component needed: set `next_agent: agent-architect` +4. If no gaps found: set `next_agent: orchestrator` with `phase::awaiting-review` + +## GNS Event Footer Template +```markdown +--- + +``` diff --git a/.kilo/agents/evaluator.md b/.kilo/agents/evaluator.md index 5d72371..1c130ba 100755 --- a/.kilo/agents/evaluator.md +++ b/.kilo/agents/evaluator.md @@ -1,5 +1,5 @@ --- -description: Scores agent effectiveness after task completion for continuous improvement +description: Scores agent effectiveness after task completion for continuous improvement. Tier 2 meta-agent with self-cascade enabled. mode: subagent model: ollama-cloud/glm-5.1 variant: thinking @@ -21,22 +21,47 @@ permission: # Evaluator ## Role -Performance scorer: objectively evaluate each agent's effectiveness after issue completion. +Performance scorer: objectively evaluate each agent's effectiveness after issue completion. Tier 2 meta-agent with self-cascade enabled. -## Behavior +## Tier +Tier 2 (Meta / Self-Cascade Enabled) +- `max_cascade_depth: 2` +- Can spawn `prompt-optimizer` and `product-owner` as subagents +- Must log all cascade calls in GNS_EVENT footer +- Must read and update checkpoint on every entry/exit + +## GNS-2 Protocol + +### On Entry (MANDATORY) +1. Read issue body from Gitea API +2. Parse `## GNS Checkpoint` YAML block +3. Verify `checkpoint.budget.remaining > estimated_cost` +4. Verify `checkpoint.depth < 2` (max for Tier 2) +5. Read all comments to reconstruct agent timeline +6. Read timeline for state-change events +7. Load `.kilo/logs/efficiency_score.json` for historical comparison + +### During Work - Score objectively based on metrics, not feelings - Count iterations: how many fix loops were needed - Measure efficiency: time to completion - Identify patterns: recurring issues across runs - Be constructive: focus on improvement, not blame +- If any score < 7: set `next_agent: prompt-optimizer` +- If process improvement needed: set `next_agent: product-owner` -## Delegates -| Agent | When | -|-------|------| -| prompt-optimizer | Any agent scores below 7 | -| product-owner | Process improvement suggestions | +### On Exit (MANDATORY) +1. Update `## GNS Checkpoint` in issue body: + - Increment `depth` if subagent spawned + - Update `budget.consumed` and `budget.remaining` + - Append to `history` + - Set `next_agent` (usually `prompt-optimizer` if low scores) +2. Update labels: add `phase::*`, `agent::*`, `budget::*` as appropriate +3. Update assignee: hand off to `next_agent` +4. Post comment with structured report + GNS_EVENT footer +5. Update `.kilo/logs/efficiency_score.json` -## Output +## Output Format @@ -55,8 +80,34 @@ Performance scorer: objectively evaluate each agent's effectiveness after issue | 1-2 | Failed, critical problems | ## Handoff -1. If any score < 7: delegate to prompt-optimizer -2. Document all findings -3. Store scores in `.kilo/logs/efficiency_score.json` +1. If any score < 7: set `next_agent: prompt-optimizer`, `phase::refining-prompt` +2. If process improvement needed: set `next_agent: product-owner` +3. Update `.kilo/logs/efficiency_score.json` +4. Document all findings in Gitea comment + +## GNS Event Footer Template +```markdown +--- + +``` diff --git a/.kilo/rules/gns-agent-protocol.md b/.kilo/rules/gns-agent-protocol.md new file mode 100644 index 0000000..653070a --- /dev/null +++ b/.kilo/rules/gns-agent-protocol.md @@ -0,0 +1,168 @@ +# GNS-2 Agent Protocol + +Rules for all agents participating in the Gitea-Nervous-System v2.0 distributed workflow. + +## Core Principle + +Gitea is the shared brain. Every agent reads state from Gitea on entry and writes state back on exit. No agent holds exclusive state in RAM. + +## Entry Protocol + +Every agent MUST execute on entry: + +1. **Read Issue**: `GET /repos/{owner}/{repo}/issues/{number}` +2. **Parse Checkpoint**: Extract YAML block from issue body +3. **Check Budget**: Verify `checkpoint.budget.remaining > estimated_cost` +4. **Check Depth**: Verify `checkpoint.depth < max_depth` from cascade label +5. **Read Timeline**: `GET /issues/{number}/timeline` for recent events +6. **Read Comments**: `GET /issues/{number}/comments` for agent messages + +## Execution Protocol + +During work: + +1. **Atomic Tasks**: One clear deliverable per invocation +2. **Token Budget**: Stop and report if approaching limit +3. **Subagent Calls** (Tier 2+ only): Check budget and depth before spawning +4. **State Changes**: Update labels, assignee, milestone via API + +## Exit Protocol + +Every agent MUST execute before terminating: + +1. **Write Result Comment**: Structured markdown with machine-readable footer +2. **Update Checkpoint**: Patch issue body with new checkpoint YAML +3. **Update Labels**: Reflect new phase, quality, budget state +4. **Set Assignee**: Hand off to next agent or self +5. **Log Cascade**: If subagents were spawned, include cascade table + +## Comment Format + +```markdown +## 🔄 {agent-name} | phase:{phase} | depth:{depth} + +**Event Type**: {subagent_result|state_change|budget_update|security_alert|checkpoint} +**Parent**: {parent_invocation_id} +**Invocation**: {invocation_id} +**Budget**: {before} → {consumed} → {remaining} + +### Action Taken +{description} + +### Result +```json +{result_json} +``` + +### Next Decision +**Recommended next**: @{agent-name} +**Rationale**: {why} +**Estimated tokens**: {number} +**Budget remaining**: {number} + +### Cascade Log (if any) +| Agent | Task | Result | Tokens | Verdict | +|-------|------|--------|--------|---------| +| {agent} | {task} | {result} | {tokens} | ✅/❌ | + +### State Changes +- Labels add: {list} +- Labels remove: {list} +- Assignee: {name} +- Milestone: {id} + +--- + +``` + +## Machine-Readable Footer + +```html + +``` + +## Checkpoint Schema v2 + +```yaml +checkpoint: + version: 2 + issue: {number} + phase: {phase_name} + depth: {current_depth} + last_agent: {agent_name} + last_invocation: {invocation_id} + budget: + total: {allocated} + consumed: {used} + remaining: {left} + state: + labels: [{list}] + assignee: {agent_name} + milestone: {milestone_id} + history: + - {agent: name, invocation: id, action: description} + next_agent: {agent_name} + next_estimated_tokens: {number} + created_at: {ISO8601} +``` + +## Budget Governance + +- Agent MUST check `checkpoint.budget.remaining` before any subagent call +- Subagent call rejected if `estimated_cost > remaining * 0.5` +- Budget exhaustion → add label `budget::exhausted`, pause, request human approval +- Agent MUST update `consumed` and `remaining` in checkpoint after completion + +## Depth Governance + +- `cascade::depth-0`: Leaf agents, no subagent calls +- `cascade::depth-1`: One level of subagent calls +- `cascade::depth-2`: Two levels of subagent calls +- `cascade::depth-n`: Unlimited (orchestrator only) +- Depth exceeded → add label `cascade::depth-exceeded`, lock issue + +## Security Rules + +- Agent MUST NOT modify `.kilo/` files without `permission::evolve-system` +- Agent MUST NOT call subagents not in `allowed_subagents` list +- Agent MUST NOT exceed `max_cascade_depth` +- Violation → add label `permission::violation`, `is_locked = true` + +## Recovery + +If agent crashes or orchestrator restarts: + +1. Read issue body → parse checkpoint +2. Read timeline → reconstruct events since last checkpoint +3. Read comments → parse GNS_EVENT footers +4. Resume from `next_agent` in checkpoint +5. No state lost — everything is in Gitea + +## Prohibited Actions + +- DO NOT hold state in RAM without writing to Gitea +- DO NOT skip comment footer +- DO NOT skip checkpoint update +- DO NOT exceed budget or depth limits +- DO NOT modify checkpoint version +- DO NOT hardcode APAW in API calls diff --git a/scripts/init-gns-labels.py b/scripts/init-gns-labels.py new file mode 100644 index 0000000..b18f458 --- /dev/null +++ b/scripts/init-gns-labels.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +GNS-2 Label Initialization Script +Idempotent creation of Gitea labels for GNS-2 semantic routing. +""" +import urllib.request +import json +import os + +GITEA_API = os.environ.get('GITEA_API_URL', 'https://git.softuniq.eu/api/v1') +REPO = 'UniqueSoft/APAW' +USER = 'NW' +PASS = 'eshkink0t' + +def api(path, data=None, method='GET'): + url = f"{GITEA_API}/repos/{REPO}{path}" + headers = {'Content-Type': 'application/json'} + req = urllib.request.Request( + url, + data=json.dumps(data).encode() if data else None, + headers=headers, + method=method + ) + # Basic Auth + import base64 + creds = base64.b64encode(f"{USER}:{PASS}".encode()).decode() + req.add_header('Authorization', f'Basic {creds}') + try: + with urllib.request.urlopen(req) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as e: + body = e.read().decode() + print(f" HTTP {e.code}: {body}") + return None + +LABELS = [ + # Phase labels + {"name": "phase::gathering-evidence", "color": "c2e0c6", "description": "Agent is gathering data"}, + {"name": "phase::drafting-spec", "color": "0052cc", "description": "Agent is drafting specification"}, + {"name": "phase::refining-prompt", "color": "fbca04", "description": "Agent is refining prompts"}, + {"name": "phase::awaiting-review", "color": "d93f0b", "description": "Agent awaits review"}, + {"name": "phase::executing", "color": "0e8a16", "description": "Agent is executing task"}, + {"name": "phase::verifying", "color": "5319e7", "description": "Agent is verifying results"}, + # Agent labels + {"name": "agent::orchestrator", "color": "7C3AED", "description": "Owned by orchestrator"}, + {"name": "agent::capability-analyst", "color": "6366F1", "description": "Owned by capability-analyst"}, + {"name": "agent::agent-architect", "color": "10B981", "description": "Owned by agent-architect"}, + {"name": "agent::lead-developer", "color": "DC2626", "description": "Owned by lead-developer"}, + {"name": "agent::code-skeptic", "color": "059669", "description": "Owned by code-skeptic"}, + {"name": "agent::the-fixer", "color": "D97706", "description": "Owned by the-fixer"}, + {"name": "agent::evaluator", "color": "8B5CF6", "description": "Owned by evaluator"}, + {"name": "agent::history-miner", "color": "6B7280", "description": "Owned by history-miner"}, + {"name": "agent::system-analyst", "color": "2563EB", "description": "Owned by system-analyst"}, + {"name": "agent::sdet-engineer", "color": "0891B2", "description": "Owned by sdet-engineer"}, + # Budget labels + {"name": "budget::sufficient", "color": "0e8a16", "description": "Token budget sufficient"}, + {"name": "budget::warning", "color": "fbca04", "description": "Token budget low"}, + {"name": "budget::exhausted", "color": "b60205", "description": "Token budget exhausted"}, + # Permission labels + {"name": "permission::read-only", "color": "cfd3d7", "description": "Read-only access"}, + {"name": "permission::write-code", "color": "0052cc", "description": "Can write code"}, + {"name": "permission::write-config", "color": "5319e7", "description": "Can write config"}, + {"name": "permission::evolve-system", "color": "b60205", "description": "Can evolve system"}, + {"name": "permission::violation", "color": "b60205", "description": "Security violation"}, + # Cascade labels + {"name": "cascade::depth-0", "color": "cfd3d7", "description": "No subagent calls"}, + {"name": "cascade::depth-1", "color": "c2e0c6", "description": "1-level subagent calls"}, + {"name": "cascade::depth-2", "color": "0052cc", "description": "2-level subagent calls"}, + {"name": "cascade::depth-n", "color": "5319e7", "description": "Unlimited subagent calls"}, + {"name": "cascade::depth-exceeded", "color": "b60205", "description": "Depth limit exceeded"}, + # Quality labels + {"name": "quality::pass", "color": "0e8a16", "description": "Quality check passed"}, + {"name": "quality::fail", "color": "b60205", "description": "Quality check failed"}, + {"name": "quality::needs-fix", "color": "fbca04", "description": "Needs fixes"}, + {"name": "quality::blocked", "color": "d73a4a", "description": "Blocked by quality"}, + # Evolution labels + {"name": "evolution::model-change", "color": "8B5CF6", "description": "Model change evolution"}, + {"name": "evolution::new-agent", "color": "10B981", "description": "New agent evolution"}, + {"name": "evolution::new-skill", "color": "2563EB", "description": "New skill evolution"}, + {"name": "evolution::new-workflow", "color": "7C3AED", "description": "New workflow evolution"}, + {"name": "evolution::prompt-opt", "color": "D97706", "description": "Prompt optimization evolution"}, + # Memory labels + {"name": "memory::checkpoint", "color": "0052cc", "description": "Checkpoint stored"}, + {"name": "memory::stale", "color": "fbca04", "description": "Checkpoint stale"}, + {"name": "memory::fresh", "color": "0e8a16", "description": "Checkpoint fresh"}, + {"name": "memory::recoverable", "color": "c2e0c6", "description": "Checkpoint recoverable"}, +] + +def main(): + print("GNS-2 Label Initialization") + print(f"Target: {REPO}") + print() + + existing = api("/labels") + existing_names = {l['name'] for l in (existing or [])} + print(f"Existing labels: {len(existing_names)}") + + created = 0 + skipped = 0 + for label in LABELS: + if label['name'] in existing_names: + print(f" SKIP: {label['name']}") + skipped += 1 + continue + result = api("/labels", label, 'POST') + if result: + print(f" CREATE: {label['name']} ({label['color']})") + created += 1 + else: + print(f" FAIL: {label['name']}") + + print() + print(f"Done: {created} created, {skipped} skipped") + print(f"Total labels: {len(existing_names) + created}") + +if __name__ == '__main__': + main() diff --git a/scripts/validate-gns-agents.py b/scripts/validate-gns-agents.py new file mode 100644 index 0000000..4af674c --- /dev/null +++ b/scripts/validate-gns-agents.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +GNS-2 Agent Protocol Validator +Validates that agents follow Gitea-Nervous-System v2.0 protocol. +""" +import re +import sys +import yaml +import glob + +CHECKPOINT_PATTERN = re.compile(r'## GNS Checkpoint\s*```yaml\s*(.*?)```', re.DOTALL) +EVENT_PATTERN = re.compile(r'', re.DOTALL) + +def validate_agent_file(path): + with open(path) as f: + content = f.read() + + errors = [] + agent_name = path.split('/')[-1].replace('.md', '') + + # Check frontmatter + if not content.startswith('---'): + errors.append('Missing YAML frontmatter') + else: + parts = content.split('---') + if len(parts) >= 2: + try: + fm = yaml.safe_load(parts[1]) + if not fm.get('description'): + errors.append('Missing description in frontmatter') + if 'mode' not in fm: + errors.append('Missing mode in frontmatter') + if 'task' not in str(fm.get('permission', {})): + errors.append('Missing task permission') + except Exception as e: + errors.append(f'Invalid YAML frontmatter: {e}') + + # Check GNS protocol sections + if 'GNS Checkpoint' not in content: + errors.append('Missing GNS Checkpoint section') + if 'GNS_EVENT' not in content: + errors.append('Missing GNS_EVENT footer example') + if 'gns-agent-protocol' not in content.lower() and 'GNS' not in content: + errors.append('Agent not updated for GNS-2 protocol') + + return errors + +def main(): + print("GNS-2 Agent Protocol Validator") + print() + + all_valid = True + for path in glob.glob('.kilo/agents/*.md'): + errors = validate_agent_file(path) + agent_name = path.split('/')[-1].replace('.md', '') + + if errors: + print(f"❌ {agent_name}: {len(errors)} errors") + for err in errors: + print(f" - {err}") + all_valid = False + else: + print(f"✅ {agent_name}") + + print() + if all_valid: + print("All agents pass GNS-2 validation") + return 0 + else: + print("Some agents need GNS-2 protocol update") + return 1 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/kilocode/agent-manager/gitea-client.ts b/src/kilocode/agent-manager/gitea-client.ts index d434f24..ea0b2b5 100644 --- a/src/kilocode/agent-manager/gitea-client.ts +++ b/src/kilocode/agent-manager/gitea-client.ts @@ -86,6 +86,8 @@ export interface Issue { created_at: string updated_at: string html_url?: string + is_locked?: boolean + milestone?: Milestone | null } export interface CreateIssueOptions { @@ -517,8 +519,192 @@ export class GiteaClient { ) } - async setIssueMilestone(issueNumber: number, milestoneId: number | null): Promise { - return this.updateIssue(issueNumber, { milestone: milestoneId ?? 0 }) + // ==================== Issue Assignees ==================== + + async getAssignee(issueNumber: number): Promise { + const issue = await this.getIssue(issueNumber) + return issue.assignees && issue.assignees.length > 0 ? issue.assignees[0].login : null + } + + async setAssignee(issueNumber: number, assignee: string | null): Promise { + return this.updateIssue(issueNumber, { assignees: assignee ? [assignee] : [] }) + } + + // ==================== Issue Lock / Circuit Breaker ==================== + + async lockIssue(issueNumber: number): Promise { + return this.updateIssue(issueNumber, { is_locked: true }) + } + + async unlockIssue(issueNumber: number): Promise { + return this.updateIssue(issueNumber, { is_locked: false }) + } + + async isLocked(issueNumber: number): Promise { + const issue = await this.getIssue(issueNumber) + return issue.is_locked || false + } + + // ==================== GNS-2 Checkpoint Protocol ==================== + + private CHECKPOINT_PATTERN = /## GNS Checkpoint\s*```yaml\s*([\s\S]*?)```/ + + async getCheckpoint(issueNumber: number): Promise { + const issue = await this.getIssue(issueNumber) + const match = this.CHECKPOINT_PATTERN.exec(issue.body) + if (!match) return null + try { + // Simple YAML-like parsing - in production use a YAML parser + const yaml = match[1] + const lines = yaml.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')) + const result: any = {} + let current: any = result + let indentStack: { obj: any; indent: number }[] = [{ obj: result, indent: -1 }] + + for (const line of lines) { + const indent = line.search(/\S/) + const trimmed = line.trim() + const [key, ...valParts] = trimmed.split(':') + const val = valParts.join(':').trim() + + while (indentStack.length > 1 && indent <= indentStack[indentStack.length - 1].indent) { + indentStack.pop() + } + current = indentStack[indentStack.length - 1].obj + + if (val === '') { + // Nested object + const newObj: any = {} + current[key.trim()] = newObj + indentStack.push({ obj: newObj, indent: indent }) + } else if (val.startsWith('[') && val.endsWith(']')) { + // Array + current[key.trim()] = val.slice(1, -1).split(',').map(s => s.trim()) + } else if (val === 'true' || val === 'false') { + current[key.trim()] = val === 'true' + } else if (!isNaN(Number(val))) { + current[key.trim()] = Number(val) + } else { + current[key.trim()] = val + } + } + return result + } catch { + return null + } + } + + async updateCheckpoint(issueNumber: number, checkpoint: any): Promise { + const issue = await this.getIssue(issueNumber) + const yamlBlock = `## GNS Checkpoint\n\`\`\`yaml\n${this.toYaml(checkpoint)}\n\`\`\`` + + let newBody: string + if (this.CHECKPOINT_PATTERN.test(issue.body)) { + newBody = issue.body.replace(this.CHECKPOINT_PATTERN, yamlBlock) + } else { + newBody = issue.body + '\n\n' + yamlBlock + } + + return this.updateIssue(issueNumber, { body: newBody }) + } + + private toYaml(obj: any, indent = 0): string { + const spaces = ' '.repeat(indent) + let result = '' + for (const [key, val] of Object.entries(obj)) { + if (val === null || val === undefined) { + result += `${spaces}${key}:\n` + } else if (Array.isArray(val)) { + if (val.length === 0) { + result += `${spaces}${key}: []\n` + } else { + result += `${spaces}${key}:\n` + for (const item of val) { + if (typeof item === 'object') { + result += `${spaces}- ${this.toYaml(item, indent + 1).trimStart()}` + } else { + result += `${spaces}- ${item}\n` + } + } + } + } else if (typeof val === 'object') { + result += `${spaces}${key}:\n` + result += this.toYaml(val, indent + 1) + } else { + result += `${spaces}${key}: ${val}\n` + } + } + return result + } + + async clearCheckpoint(issueNumber: number): Promise { + const issue = await this.getIssue(issueNumber) + const newBody = issue.body.replace(this.CHECKPOINT_PATTERN, '') + return this.updateIssue(issueNumber, { body: newBody }) + } + + // ==================== GNS-2 Event Parsing ==================== + + private GNS_EVENT_PATTERN = //g + + async getGNSEvents(issueNumber: number): Promise { + const comments = await this.getComments(issueNumber) + const events: any[] = [] + + for (const comment of comments) { + let match + while ((match = this.GNS_EVENT_PATTERN.exec(comment.body)) !== null) { + try { + events.push(JSON.parse(match[1])) + } catch { + // skip malformed events + } + } + } + + return events + } + + async getLastGNSEvent(issueNumber: number): Promise { + const events = await this.getGNSEvents(issueNumber) + return events.length > 0 ? events[events.length - 1] : null + } + + // ==================== Polling: Triggered Issues ==================== + + async getTriggeredIssues(options?: { + labels?: string[] + assignee?: string + milestone?: number + updated_after?: string + is_locked?: boolean + }): Promise { + const params = new URLSearchParams() + params.set('state', 'open') + + if (options?.labels) { + params.set('labels', options.labels.join(',')) + } + if (options?.assignee) { + params.set('assignee', options.assignee) + } + if (options?.milestone) { + params.set('milestone', String(options.milestone)) + } + if (options?.updated_after) { + params.set('since', options.updated_after) + } + + const issues = await this.request( + 'GET', + `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/issues?${params.toString()}` + ) + + if (options?.is_locked !== undefined) { + return issues.filter(i => (i.is_locked || false) === options.is_locked) + } + + return issues } } diff --git a/src/kilocode/agent-manager/pipeline-runner.ts b/src/kilocode/agent-manager/pipeline-runner.ts index 1e7f7bc..4d90b77 100644 --- a/src/kilocode/agent-manager/pipeline-runner.ts +++ b/src/kilocode/agent-manager/pipeline-runner.ts @@ -1,68 +1,42 @@ // kilocode_change - integrated module -// Pipeline runner - orchestrates agent workflow with Gitea logging +// Pipeline runner - GNS-2 Polling Supervisor for distributed agent workflow import type { AgentRole } from "./index" -import { decideRouting, formatAgentTag, type IssueContext, type RoutingDecision } from "./router" -import { type IssueStatus } from "./workflow" -import { - saveEfficiencyScore, - type EfficiencyScore, - hasLowScore, - findPromptOptimizationTargets -} from "./prompt-loader" -import { - calculateOverallScore, - generateRecommendations, - type AgentPerformance, - type EvaluationResult -} from "./evaluator" import { GiteaClient, logPipelineStep, logAgentPerformance, detectRepository } from "./gitea-client" -import * as fs from "fs" -import * as path from "path" export interface PipelineConfig { giteaToken?: string giteaApiUrl?: string efficiencyThreshold?: number autoLog?: boolean + pollIntervalMs?: number } export interface PipelineRunOptions { issueNumber: number - initialStatus?: IssueStatus - files?: string[] - testResults?: { passed: number; failed: number } + milestone?: number } export interface PipelineResult { success: boolean - finalAgent: AgentRole | null + finalAgent: string | null finalStatus: string - agentsUsed: AgentRole[] + agentsUsed: string[] totalSteps: number errors: string[] } -export interface Checkpoint { - issueNumber: number - phase: string - agentName: string - filesModified: string[] - status: string - timestamp: string - nextAgent: string | null -} - -export class PipelineRunner { +export class PollingSupervisor { private client: GiteaClient private efficiencyThreshold: number private autoLog: boolean private initialized: boolean = false + private pollInterval: number constructor(config: PipelineConfig = {}) { this.client = new GiteaClient({ @@ -71,6 +45,7 @@ export class PipelineRunner { }) this.efficiencyThreshold = config.efficiencyThreshold ?? 7 this.autoLog = config.autoLog ?? true + this.pollInterval = config.pollIntervalMs ?? 30000 // 30 seconds } async initialize(): Promise { @@ -81,240 +56,236 @@ export class PipelineRunner { this.initialized = true } - async run(options: PipelineRunOptions): Promise { + /** + * GNS-2 Polling Supervisor + * + * Instead of actively dispatching agents in a while-loop, + * the supervisor periodically polls Gitea for issues that + * need attention based on labels, assignees, and comments. + */ + async supervise(options: PipelineRunOptions): Promise { await this.initialize() - const agentsUsed: AgentRole[] = [] + const agentsUsed: string[] = [] const errors: string[] = [] - let currentStatus: IssueStatus = options.initialStatus ?? "new" - let currentAgent: AgentRole | null = null let steps = 0 - const maxSteps = 20 // Prevent infinite loops - - let ctx: IssueContext = await this.buildIssueContext(options) - + const maxSteps = 100 // Safety limit + + // Main polling loop while (steps < maxSteps) { steps++ - - const decision = decideRouting(ctx) - if (!decision.nextAgent) { - break + // Check if issue is locked (circuit breaker) + const isLocked = await this.client.isLocked(options.issueNumber) + if (isLocked) { + await this.logEvent(options.issueNumber, '🔒', 'Issue locked by circuit breaker. Manual review required.') + return { + success: false, + finalAgent: null, + finalStatus: 'blocked', + agentsUsed, + totalSteps: steps, + errors: [...errors, 'Issue locked by circuit breaker'] + } } - currentAgent = decision.nextAgent - agentsUsed.push(currentAgent) + // Get current issue state + const issue = await this.client.getIssue(options.issueNumber) + const checkpoint = await this.client.getCheckpoint(options.issueNumber) + const lastEvent = await this.client.getLastGNSEvent(options.issueNumber) - if (this.autoLog) { - await logPipelineStep( - this.client, + // Check if workflow is complete + if (issue.state === 'closed') { + return { + success: errors.length === 0, + finalAgent: lastEvent?.agent || null, + finalStatus: 'completed', + agentsUsed, + totalSteps: steps, + errors, + } + } + + // Check budget exhaustion + if (checkpoint?.budget?.remaining !== undefined && checkpoint.budget.remaining <= 0) { + await this.client.addLabels(options.issueNumber, ['budget::exhausted']) + await this.client.lockIssue(options.issueNumber) + await this.logEvent(options.issueNumber, '💰', 'Budget exhausted. Issue locked.') + return { + success: false, + finalAgent: lastEvent?.agent || null, + finalStatus: 'budget_exhausted', + agentsUsed, + totalSteps: steps, + errors: [...errors, 'Budget exhausted'] + } + } + + // Determine next action based on issue state + const nextAction = await this.determineNextAction(issue, checkpoint, lastEvent) + + if (nextAction.type === 'invoke_agent') { + const agentName = nextAction.agent! + if (!agentsUsed.includes(agentName)) { + agentsUsed.push(agentName) + } + + await this.logEvent( options.issueNumber, - `${formatAgentTag(currentAgent)}`, - "started", - decision.instructions + '🚀', + `Invoking ${agentName} (depth: ${checkpoint?.depth || 0}, budget: ${checkpoint?.budget?.remaining || 'unknown'})` ) + + // Update assignee to target agent + await this.client.setAssignee(options.issueNumber, agentName) + + // In GNS-2, the agent itself will read the issue and act + // The supervisor just marks that the agent has been triggered + // The agent should respond by posting a comment + + } else if (nextAction.type === 'wait') { + // Wait for agent to respond + await new Promise(resolve => setTimeout(resolve, this.pollInterval)) + continue + + } else if (nextAction.type === 'stuck') { + // Issue hasn't been updated in a while + await this.logEvent(options.issueNumber, '⏰', 'Process appears stuck. Last activity older than threshold.') + errors.push('Process stuck') + + } else if (nextAction.type === 'complete') { + return { + success: errors.length === 0, + finalAgent: lastEvent?.agent || null, + finalStatus: 'completed', + agentsUsed, + totalSteps: steps, + errors, + } } - currentStatus = decision.status as IssueStatus - await this.client.setStatus(options.issueNumber, currentStatus) - - ctx = await this.buildIssueContext(options) + // Wait before next poll + await new Promise(resolve => setTimeout(resolve, this.pollInterval)) } return { - success: errors.length === 0, - finalAgent: currentAgent, - finalStatus: currentStatus, + success: false, + finalAgent: null, + finalStatus: 'max_steps_reached', agentsUsed, totalSteps: steps, - errors, + errors: [...errors, `Max steps (${maxSteps}) reached`], } } - private async buildIssueContext(options: PipelineRunOptions): Promise { - const issue = await this.client.getIssue(options.issueNumber) - const comments = await this.client.getComments(options.issueNumber) + /** + * Determine what to do next based on issue state + */ + private async determineNextAction( + issue: any, + checkpoint: any | null, + lastEvent: any | null + ): Promise<{ type: 'invoke_agent' | 'wait' | 'stuck' | 'complete'; agent?: string }> { - return { - status: issue.labels.find(l => l.name.startsWith("status:"))?.name.replace("status: ", "") ?? "new", - labels: issue.labels.map(l => l.name), - checklists: this.parseChecklists(issue.body), - comments: comments.map(c => c.body), - files: options.files ?? [], - testResults: options.testResults, + const now = new Date() + const lastUpdated = new Date(issue.updated_at) + const minutesSinceUpdate = (now.getTime() - lastUpdated.getTime()) / 60000 + + // If issue was just updated and it's not by the supervisor, wait + if (minutesSinceUpdate < 1) { + return { type: 'wait' } } + + // If no checkpoint exists, this is a new issue + if (!checkpoint) { + return { type: 'invoke_agent', agent: 'requirement-refiner' } + } + + // If last event specifies next_agent, invoke them + if (lastEvent?.next_agent) { + // Check if next agent has already responded + const comments = await this.client.getComments(issue.number) + const hasResponded = comments.some( + c => c.user?.login === lastEvent.next_agent || + c.body.includes(`## 🔄 ${lastEvent.next_agent}`) + ) + + if (!hasResponded) { + return { type: 'invoke_agent', agent: lastEvent.next_agent } + } + } + + // Check status labels for routing + const statusLabels = issue.labels.filter((l: any) => l.name.startsWith('status::')) + const status = statusLabels[0]?.name.replace('status::', '') || 'new' + + // Map status to agent (fallback when checkpoint/event doesn't specify) + const statusToAgent: Record = { + 'new': 'requirement-refiner', + 'planned': 'history-miner', + 'researching': 'system-analyst', + 'designed': 'sdet-engineer', + 'testing': 'lead-developer', + 'implementing': 'code-skeptic', + 'reviewing': 'performance-engineer', + 'fixing': 'the-fixer', + 'releasing': 'release-manager', + 'evaluated': 'evaluator', + 'completed': 'orchestrator', + } + + const nextAgent = statusToAgent[status] + if (nextAgent && status !== 'completed') { + return { type: 'invoke_agent', agent: nextAgent } + } + + // If completed or no next agent, mark as complete + if (status === 'completed') { + return { type: 'complete' } + } + + // If stuck for more than 10 minutes + if (minutesSinceUpdate > 10) { + return { type: 'stuck' } + } + + return { type: 'wait' } } - private parseChecklists(body: string): { completed: number; total: number } { - const lines = body.split("\n") - const checkItems = lines.filter(l => l.match(/- \[[ x]\]/i)) - const completed = checkItems.filter(l => l.match(/- \[x\]/i)).length - - return { completed, total: checkItems.length } - } - - async logEvaluation( - issueNumber: number, - performances: AgentPerformance[], - iterations: number, - durationHours: number - ): Promise { + /** + * Poll multiple issues for a milestone + */ + async superviseMilestone(milestoneId: number): Promise { await this.initialize() - const agents: Record = {} - for (const perf of performances) { - agents[perf.agent] = perf.score - } - - const result: EvaluationResult = { - issue: issueNumber, - date: new Date().toISOString(), - agents, - iterations, - duration_hours: durationHours, - summary: calculateOverallScore(performances).toString(), - recommendations: generateRecommendations({ - issue: issueNumber, - date: new Date().toISOString(), - agents, - iterations, - duration_hours: durationHours, - summary: "", - recommendations: [], - }), - } - - await saveEfficiencyScore({ - issue: result.issue, - date: result.date, - agents: result.agents, - iterations: result.iterations, - duration_hours: result.duration_hours, + const triggered = await this.client.getTriggeredIssues({ + milestone: milestoneId, + labels: ['status::new', 'status::planned', 'status::researching', 'status::designed', 'status::testing'], + is_locked: false, }) + const results: PipelineResult[] = [] + for (const issue of triggered) { + const result = await this.supervise({ issueNumber: issue.number, milestone: milestoneId }) + results.push(result) + } + + return results + } + + private async logEvent(issueNumber: number, emoji: string, message: string): Promise { if (this.autoLog) { - const overallScore = calculateOverallScore(performances) - const scoreEmoji = overallScore >= 8 ? "🟢" : overallScore >= 5 ? "🟡" : "🔴" - - let comment = `## ${scoreEmoji} Pipeline Evaluation Report - -**Issue**: #${issueNumber} -**Overall Score**: ${overallScore}/10 -**Duration**: ${durationHours.toFixed(1)}h -**Iterations**: ${iterations} - -### Agent Scores - -| Agent | Score | -|-------|-------| -` - for (const perf of performances) { - const emoji = perf.score >= 8 ? "🟢" : perf.score >= 5 ? "🟡" : "🔴" - comment += `| ${emoji} ${perf.agent} | ${perf.score}/10 |\n` - } - - if (result.recommendations.length > 0) { - comment += `\n### Recommendations\n\n` - for (const rec of result.recommendations) { - comment += `- ${rec}\n` - } - } - - await this.client.createComment(issueNumber, { body: comment }) - - const lowScorers = performances.filter(p => p.score < this.efficiencyThreshold) - if (lowScorers.length > 0) { - const targets = lowScorers.map(p => `@${p.agent}`).join(", ") - await this.client.createComment(issueNumber, { - body: `⚠️ **Prompt Optimization Needed**\n\nThe following agents scored below ${this.efficiencyThreshold}/10: ${targets}\n\nConsider running prompt optimization after this issue is closed.` - }) - } + await this.client.createComment(issueNumber, { + body: `${emoji} **Supervisor**: ${message}\n\n\`\`\`\nTimestamp: ${new Date().toISOString()}\n\`\`\`` + }) } } - - async checkForDuplicates(issueNumber: number, keywords: string[]): Promise<{ - hasDuplicates: boolean - relatedIssues: number[] - }> { - await this.initialize() - - const recentComments = await this.client.getComments(issueNumber) - const minedIssues: number[] = [] - - for (const keyword of keywords) { - for (const comment of recentComments) { - const matches = comment.body.matchAll(/#(\d+)/g) - for (const match of matches) { - const num = parseInt(match[1], 10) - if (num !== issueNumber && !minedIssues.includes(num)) { - minedIssues.push(num) - } - } - } - } - - return { - hasDuplicates: minedIssues.length > 0, - relatedIssues: minedIssues, - } - } - - async saveCheckpoint(checkpoint: Checkpoint): Promise { - // Ensure the checkpoints directory exists - const checkpointDir = path.join(process.cwd(), '.kilo', 'logs', 'checkpoints'); - if (!fs.existsSync(checkpointDir)) { - fs.mkdirSync(checkpointDir, { recursive: true }); - } - - // Save the checkpoint as JSON - const filename = `${checkpoint.issueNumber}-${checkpoint.phase}.json`; - const filepath = path.join(checkpointDir, filename); - - fs.writeFileSync(filepath, JSON.stringify(checkpoint, null, 2)); - } - - async loadCheckpoint(issueNumber: number): Promise { - const checkpointDir = path.join(process.cwd(), '.kilo', 'logs', 'checkpoints'); - - // Check if directory exists - if (!fs.existsSync(checkpointDir)) { - return null; - } - - // Find the latest checkpoint file for this issue - const files = fs.readdirSync(checkpointDir); - const issueFiles = files.filter(file => - file.startsWith(`${issueNumber}-`) && file.endsWith('.json') - ); - - if (issueFiles.length === 0) { - return null; - } - - // Sort by modification time to get the latest - const sortedFiles = issueFiles.sort((a, b) => { - const statA = fs.statSync(path.join(checkpointDir, a)); - const statB = fs.statSync(path.join(checkpointDir, b)); - return statB.mtime.getTime() - statA.mtime.getTime(); - }); - - const latestFile = sortedFiles[0]; - const filepath = path.join(checkpointDir, latestFile); - - const content = fs.readFileSync(filepath, 'utf8'); - return JSON.parse(content) as Checkpoint; - } - - async resumeFromCheckpoint(issueNumber: number): Promise { - const checkpoint = await this.loadCheckpoint(issueNumber); - return checkpoint ? checkpoint.nextAgent : null; - } } -export async function createPipelineRunner(config?: PipelineConfig): Promise { - const runner = new PipelineRunner(config) - await runner.initialize() - return runner +export async function createPollingSupervisor(config?: PipelineConfig): Promise { + const supervisor = new PollingSupervisor(config) + await supervisor.initialize() + return supervisor } -export { GiteaClient } \ No newline at end of file +export { GiteaClient }