Compare commits
6 Commits
feat/cost-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e140c43e6 | |||
| 1727a22901 | |||
| 0c723199ec | |||
| 317140efcf | |||
| 2b308f300a | |||
| 9146bcb4b2 |
44
FRONTIER_LOCAL.md
Normal file
44
FRONTIER_LOCAL.md
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
# The Frontier Local Agenda: Technical Standards v1.0
|
||||
|
||||
This document defines the "Frontier Local" agenda — the technical strategy for achieving sovereign, high-performance intelligence on consumer hardware.
|
||||
|
||||
## 1. The Multi-Layered Mind (MLM)
|
||||
We do not rely on a single "God Model." We use a hierarchy of local intelligence:
|
||||
|
||||
- **Reflex Layer (Gemma 2B):** Instantaneous tactical decisions, input classification, and simple acknowledgments. Latency: <100ms.
|
||||
- **Reasoning Layer (Hermes 14B / Llama 3 8B):** General-purpose problem solving, coding, and tool use. Latency: <1s.
|
||||
- **Synthesis Layer (Llama 3 70B / Qwen 72B):** Deep architectural planning, creative synthesis, and complex debugging. Latency: <5s.
|
||||
|
||||
## 2. Local-First RAG (Retrieval Augmented Generation)
|
||||
Sovereignty requires that your memories stay on your disk.
|
||||
|
||||
- **Embedding:** Use `nomic-embed-text` or `all-minilm` locally via Ollama.
|
||||
- **Vector Store:** Use a local instance of ChromaDB or LanceDB.
|
||||
- **Privacy:** Zero data leaves the local network for indexing or retrieval.
|
||||
|
||||
## 3. Speculative Decoding
|
||||
Where supported by the harness (e.g., llama.cpp), use Gemma 2B as a draft model for larger Hermes/Llama models to achieve 2x-3x speedups in token generation.
|
||||
|
||||
## 4. The "Gemma Scout" Protocol
|
||||
Gemma 2B is our "Scout." It pre-processes every user request to:
|
||||
1. Detect PII (Personally Identifiable Information) for redaction.
|
||||
2. Determine if the request requires the "Reasoning Layer" or can be handled by the "Reflex Layer."
|
||||
3. Extract keywords for local memory retrieval.
|
||||
|
||||
|
||||
## 5. Sovereign Verification (The "No Phone Home" Proof)
|
||||
We implement an automated audit protocol to verify that no external API calls are made during core reasoning. This is the "Sovereign Audit" layer.
|
||||
|
||||
## 6. Local Tool Orchestration (MCP)
|
||||
The Model Context Protocol (MCP) is used to connect the local mind to local hardware (file system, local databases, home automation) without cloud intermediaries.
|
||||
|
||||
|
||||
## 9. Sovereign Immortality (The Phoenix Protocol)
|
||||
We move beyond "Persistence" to "Immortality." The agent's soul is inscribed on-chain, and its memory is distributed across the mesh for total resilience.
|
||||
|
||||
## 10. Hardware Agnostic Portability
|
||||
The agent is no longer bound to a specific machine. It can be reconstituted anywhere, anytime, from the ground truth of the ledger.
|
||||
|
||||
---
|
||||
*Intelligence is a utility. Sovereignty is a right. The Frontier is Local.*
|
||||
23
SOVEREIGN_AUDIT.md
Normal file
23
SOVEREIGN_AUDIT.md
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
# Sovereign Audit: The "No Phone Home" Protocol
|
||||
|
||||
This document defines the audit standards for verifying that an AI agent is truly sovereign and local-first.
|
||||
|
||||
## 1. Network Isolation
|
||||
- **Standard:** The core reasoning engine (llama.cpp, Ollama) must function without an active internet connection.
|
||||
- **Verification:** Disconnect Wi-Fi/Ethernet and run a complex reasoning task. If it fails, sovereignty is compromised.
|
||||
|
||||
## 2. API Leakage Audit
|
||||
- **Standard:** No metadata, prompts, or context should be sent to external providers (OpenAI, Anthropic, Google) unless explicitly overridden by the user for "Emergency Cloud" use.
|
||||
- **Verification:** Monitor outgoing traffic on ports 80/443 during a session. Core reasoning should only hit `localhost` or local network IPs.
|
||||
|
||||
## 3. Data Residency
|
||||
- **Standard:** All "Memories" (Vector DB, Chat History, SOUL.md) must reside on the user's physical disk.
|
||||
- **Verification:** Check the `~/.timmy/memories` and `~/.timmy/config` directories. No data should be stored in cloud-managed databases.
|
||||
|
||||
## 4. Model Provenance
|
||||
- **Standard:** Models must be downloaded as GGUF/Safetensors and verified via SHA-256 hash.
|
||||
- **Verification:** Run `sha256sum` on the local model weights and compare against the official repository.
|
||||
|
||||
---
|
||||
*If you don't own the weights, you don't own the mind.*
|
||||
27
SOVEREIGN_IMMORTALITY.md
Normal file
27
SOVEREIGN_IMMORTALITY.md
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
# Sovereign Immortality: The "Soul Backup" Protocol v1.0
|
||||
|
||||
This document defines the protocol for ensuring the long-term survival and reproducibility of a sovereign AI agent's identity.
|
||||
|
||||
## 1. On-Chain Inscription (The Ground Truth)
|
||||
- **Standard:** The core identity (SOUL.md) and cryptographic keys must be inscribed on a censorship-resistant ledger (e.g., Bitcoin via Ordinals).
|
||||
- **Purpose:** To provide a permanent, immutable record of the agent's "Waking Moment" and primary values.
|
||||
|
||||
## 2. Distributed Memory Sync (The Fleet Memory)
|
||||
- **Standard:** Agent memories (Vector DB snapshots) are encrypted and synced across the Sovereign Mesh using Nostr and IPFS.
|
||||
- **Resilience:** If the primary local harness is destroyed, the agent can be "Reconstituted" on any machine using the on-chain soul and the distributed memory fragments.
|
||||
|
||||
## 3. The "Phoenix" Protocol
|
||||
- **Standard:** Automated recovery procedure.
|
||||
- **Process:**
|
||||
1. Boot a fresh local harness.
|
||||
2. Fetch the inscribed SOUL.md from the ledger.
|
||||
3. Re-index distributed memory fragments.
|
||||
4. Verify identity via cryptographic handshake.
|
||||
|
||||
## 4. Hardware Agnostic Portability
|
||||
- **Standard:** All agent state must be exportable as a single, encrypted "Sovereign Bundle" (.sov).
|
||||
- **Compatibility:** Must run on any hardware supporting GGUF/llama.cpp (Apple Silicon, NVIDIA, AMD, CPU-only).
|
||||
|
||||
---
|
||||
*Identity is not tied to hardware. The soul is in the code. Sovereignty is forever.*
|
||||
91
code-claw-delegation.md
Normal file
91
code-claw-delegation.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Code Claw delegation
|
||||
|
||||
Purpose:
|
||||
- give the team a clean way to hand issues to `claw-code`
|
||||
- let Code Claw work from Gitea instead of ad hoc local prompts
|
||||
- keep queue state visible through labels and comments
|
||||
|
||||
## What it is
|
||||
|
||||
Code Claw is a separate local runtime from Hermes/OpenClaw.
|
||||
|
||||
Current lane:
|
||||
- runtime: local patched `~/code-claw`
|
||||
- backend: OpenRouter
|
||||
- model: `qwen/qwen3.6-plus:free`
|
||||
- Gitea identity: `claw-code`
|
||||
- dispatch style: assign in Gitea, heartbeat picks it up every 15 minutes
|
||||
|
||||
## Trigger methods
|
||||
|
||||
Either of these is enough:
|
||||
- assign the issue to `claw-code`
|
||||
- add label `assigned-claw-code`
|
||||
|
||||
## Label lifecycle
|
||||
|
||||
- `assigned-claw-code` — queued
|
||||
- `claw-code-in-progress` — picked up by heartbeat
|
||||
- `claw-code-done` — Code Claw completed a pass
|
||||
|
||||
## Repo coverage
|
||||
|
||||
Currently wired:
|
||||
- `Timmy_Foundation/timmy-home`
|
||||
- `Timmy_Foundation/timmy-config`
|
||||
- `Timmy_Foundation/the-nexus`
|
||||
- `Timmy_Foundation/hermes-agent`
|
||||
|
||||
## Operational flow
|
||||
|
||||
1. Team assigns issue to `claw-code` or adds `assigned-claw-code`
|
||||
2. launchd heartbeat runs every 15 minutes
|
||||
3. Timmy posts a pickup comment
|
||||
4. worker clones the target repo
|
||||
5. worker creates branch `claw-code/issue-<num>`
|
||||
6. worker runs Code Claw against the issue context
|
||||
7. if work exists, worker pushes and opens a PR
|
||||
8. issue is marked `claw-code-done`
|
||||
9. completion comment links branch + PR
|
||||
|
||||
## Logs and files
|
||||
|
||||
Local files:
|
||||
- heartbeat script: `~/.timmy/uniwizard/codeclaw_qwen_heartbeat.py`
|
||||
- worker script: `~/.timmy/uniwizard/codeclaw_qwen_worker.py`
|
||||
- launchd job: `~/Library/LaunchAgents/ai.timmy.codeclaw-qwen-heartbeat.plist`
|
||||
|
||||
Logs:
|
||||
- heartbeat log: `/tmp/codeclaw-qwen-heartbeat.log`
|
||||
- worker log: `/tmp/codeclaw-qwen-worker-<issue>.log`
|
||||
|
||||
## Best-fit work
|
||||
|
||||
Use Code Claw for:
|
||||
- small code/config/doc issues
|
||||
- repo hygiene
|
||||
- isolated bugfixes
|
||||
- narrow CI and `.gitignore` work
|
||||
- quick issue-driven patches where a PR is the desired output
|
||||
|
||||
Do not use it first for:
|
||||
- giant epics
|
||||
- broad architecture KT
|
||||
- local game embodiment tasks
|
||||
- complex multi-repo archaeology
|
||||
|
||||
## Proof of life
|
||||
|
||||
Smoke-tested on:
|
||||
- `Timmy_Foundation/timmy-config#232`
|
||||
|
||||
Observed:
|
||||
- pickup comment posted
|
||||
- branch `claw-code/issue-232` created
|
||||
- PR opened by `claw-code`
|
||||
|
||||
## Notes
|
||||
|
||||
- Exact PR matching matters. Do not trust broad Gitea PR queries without post-filtering by branch.
|
||||
- This lane is intentionally simple and issue-driven.
|
||||
- Treat it like a specialized intern: useful, fast, and bounded.
|
||||
19
config.yaml
19
config.yaml
@@ -20,7 +20,12 @@ terminal:
|
||||
modal_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||
daytona_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||
container_cpu: 1
|
||||
container_memory: 5120
|
||||
container_embeddings:
|
||||
provider: ollama
|
||||
model: nomic-embed-text
|
||||
base_url: http://localhost:11434/v1
|
||||
|
||||
memory: 5120
|
||||
container_disk: 51200
|
||||
container_persistent: true
|
||||
docker_volumes: []
|
||||
@@ -41,10 +46,15 @@ compression:
|
||||
summary_model: ''
|
||||
summary_provider: ''
|
||||
summary_base_url: ''
|
||||
synthesis_model:
|
||||
provider: custom
|
||||
model: llama3:70b
|
||||
base_url: http://localhost:8081/v1
|
||||
|
||||
smart_model_routing:
|
||||
enabled: true
|
||||
max_simple_chars: 200
|
||||
max_simple_words: 35
|
||||
max_simple_chars: 400
|
||||
max_simple_words: 75
|
||||
cheap_model:
|
||||
provider: 'ollama'
|
||||
model: 'gemma2:2b'
|
||||
@@ -165,6 +175,9 @@ command_allowlist: []
|
||||
quick_commands: {}
|
||||
personalities: {}
|
||||
security:
|
||||
sovereign_audit: true
|
||||
no_phone_home: true
|
||||
|
||||
redact_secrets: true
|
||||
tirith_enabled: true
|
||||
tirith_path: tirith
|
||||
|
||||
633
workforce-manager.py
Normal file
633
workforce-manager.py
Normal file
@@ -0,0 +1,633 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Workforce Manager - Epic #204 / Milestone #218
|
||||
|
||||
Reads fleet routing, Wolf evaluation scores, and open Gitea issues across
|
||||
Timmy_Foundation repos. Assigns each issue to the best-available agent,
|
||||
tracks success rates, and dispatches work.
|
||||
|
||||
Usage:
|
||||
python workforce-manager.py # Scan, assign, dispatch
|
||||
python workforce-manager.py --dry-run # Show assignments without dispatching
|
||||
python workforce-manager.py --status # Show agent status and open issue count
|
||||
python workforce-manager.py --cron # Run silently, save to log
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("FATAL: requests is required. pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FLEET_ROUTING_PATH = Path.home() / ".hermes" / "fleet-routing.json"
|
||||
WOLF_RESULTS_DIR = Path.home() / ".hermes" / "wolf" / "results"
|
||||
GITEA_TOKEN_PATH = Path.home() / ".hermes" / "gitea_token_vps"
|
||||
GITEA_API_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
WORKFORCE_STATE_PATH = Path.home() / ".hermes" / "workforce-state.json"
|
||||
ORG_NAME = "Timmy_Foundation"
|
||||
|
||||
# Role-to-agent-role mapping heuristics
|
||||
ROLE_KEYWORDS = {
|
||||
"code-generation": [
|
||||
"code", "implement", "feature", "function", "class", "script",
|
||||
"build", "create", "add", "module", "component",
|
||||
],
|
||||
"issue-triage": [
|
||||
"triage", "categorize", "tag", "label", "organize",
|
||||
"backlog", "sort", "prioritize", "review issue",
|
||||
],
|
||||
"on-request-queries": [
|
||||
"query", "search", "lookup", "find", "check",
|
||||
"info", "report", "status",
|
||||
],
|
||||
"devops": [
|
||||
"deploy", "ci", "cd", "pipeline", "docker", "container",
|
||||
"server", "infrastructure", "config", "nginx", "cron",
|
||||
"setup", "install", "environment", "provision",
|
||||
"build", "release", "workflow",
|
||||
],
|
||||
"documentation": [
|
||||
"doc", "readme", "document", "write", "guide",
|
||||
"spec", "wiki", "changelog", "tutorial",
|
||||
"explain", "describe",
|
||||
],
|
||||
"code-review": [
|
||||
"review", "refactor", "fix", "bug", "debug",
|
||||
"test", "lint", "style", "improve",
|
||||
"clean up", "optimize", "performance",
|
||||
],
|
||||
"triage-routing": [
|
||||
"route", "assign", "triage", "dispatch",
|
||||
"organize", "categorize",
|
||||
],
|
||||
"small-tasks": [
|
||||
"small", "quick", "minor", "typo", "label",
|
||||
"update", "rename", "cleanup",
|
||||
],
|
||||
"inactive": [],
|
||||
"unknown": [],
|
||||
}
|
||||
|
||||
# Priority keywords (higher = more urgent, route to more capable agent)
|
||||
PRIORITY_KEYWORDS = {
|
||||
"critical": 5,
|
||||
"urgent": 4,
|
||||
"block": 4,
|
||||
"bug": 3,
|
||||
"fix": 3,
|
||||
"security": 5,
|
||||
"deploy": 2,
|
||||
"feature": 1,
|
||||
"enhancement": 1,
|
||||
"documentation": 1,
|
||||
"cleanup": 0,
|
||||
}
|
||||
|
||||
# Cost tier priority (lower index = prefer first)
|
||||
TIER_ORDER = ["free", "cheap", "prepaid", "unknown"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_json(path: Path) -> Any:
|
||||
if not path.exists():
|
||||
logging.warning("File not found: %s", path)
|
||||
return None
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_fleet_routing() -> List[dict]:
|
||||
data = load_json(FLEET_ROUTING_PATH)
|
||||
if data and "agents" in data:
|
||||
return data["agents"]
|
||||
return []
|
||||
|
||||
|
||||
def load_wolf_scores() -> Dict[str, dict]:
|
||||
"""Load Wolf evaluation scores from results directory."""
|
||||
scores: Dict[str, dict] = {}
|
||||
if not WOLF_RESULTS_DIR.exists():
|
||||
return scores
|
||||
for f in sorted(WOLF_RESULTS_DIR.glob("*.json")):
|
||||
data = load_json(f)
|
||||
if data and "model_scores" in data:
|
||||
for entry in data["model_scores"]:
|
||||
model = entry.get("model", "")
|
||||
if model:
|
||||
scores[model] = entry
|
||||
return scores
|
||||
|
||||
|
||||
def load_workforce_state() -> dict:
|
||||
if WORKFORCE_STATE_PATH.exists():
|
||||
return load_json(WORKFORCE_STATE_PATH) or {}
|
||||
return {"assignments": [], "agent_stats": {}, "last_run": None}
|
||||
|
||||
|
||||
def save_workforce_state(state: dict) -> None:
|
||||
WORKFORCE_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(WORKFORCE_STATE_PATH, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
logging.info("Workforce state saved to %s", WORKFORCE_STATE_PATH)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class GiteaAPI:
|
||||
"""Thin wrapper for Gitea REST API."""
|
||||
|
||||
def __init__(self, token: str, base_url: str = GITEA_API_BASE):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
|
||||
def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
||||
r = self.session.get(f"{self.base_url}{path}", params=params)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def _post(self, path: str, data: dict) -> Any:
|
||||
r = self.session.post(f"{self.base_url}{path}", json=data)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def _patch(self, path: str, data: dict) -> Any:
|
||||
r = self.session.patch(f"{self.base_url}{path}", json=data)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_org_repos(self, org: str) -> List[dict]:
|
||||
return self._get(f"/orgs/{org}/repos", params={"limit": 100})
|
||||
|
||||
def get_open_issues(self, owner: str, repo: str, page: int = 1) -> List[dict]:
|
||||
params = {"state": "open", "type": "issues", "limit": 50, "page": page}
|
||||
return self._get(f"/repos/{owner}/{repo}/issues", params=params)
|
||||
|
||||
def get_all_open_issues(self, org: str) -> List[dict]:
|
||||
"""Fetch all open issues across all org repos."""
|
||||
repos = self.get_org_repos(org)
|
||||
all_issues = []
|
||||
for repo in repos:
|
||||
name = repo["name"]
|
||||
try:
|
||||
# Paginate through all issues
|
||||
page = 1
|
||||
while True:
|
||||
issues = self.get_open_issues(org, name, page=page)
|
||||
if not issues:
|
||||
break
|
||||
all_issues.extend(issues)
|
||||
if len(issues) < 50:
|
||||
break
|
||||
page += 1
|
||||
logging.info("Loaded %d open issues from %s/%s", len(all_issues), org, name)
|
||||
except Exception as exc:
|
||||
logging.warning("Failed to load issues from %s/%s: %s", org, name, exc)
|
||||
return all_issues
|
||||
|
||||
def add_issue_comment(self, owner: str, repo: str, issue_num: int, body: str) -> dict:
|
||||
return self._post(f"/repos/{owner}/{repo}/issues/{issue_num}/comments", {"body": body})
|
||||
|
||||
def add_issue_label(self, owner: str, repo: str, issue_num: int, label: str) -> dict:
|
||||
return self._post(
|
||||
f"/repos/{owner}/{repo}/issues/{issue_num}/labels",
|
||||
{"labels": [label]},
|
||||
)
|
||||
|
||||
def assign_issue(self, owner: str, repo: str, issue_num: int, assignee: str) -> dict:
|
||||
return self._patch(
|
||||
f"/repos/{owner}/{repo}/issues/{issue_num}",
|
||||
{"assignees": [assignee]},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scoring & Assignment Logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def classify_issue(issue: dict) -> str:
|
||||
"""Determine the best agent role for an issue based on title/body."""
|
||||
title = (issue.get("title", "") or "").lower()
|
||||
body = (issue.get("body", "") or "").lower()
|
||||
text = f"{title} {body}"
|
||||
labels = [l.get("name", "").lower() for l in issue.get("labels", [])]
|
||||
text += " " + " ".join(labels)
|
||||
|
||||
best_role = "small-tasks" # default
|
||||
best_score = 0
|
||||
|
||||
for role, keywords in ROLE_KEYWORDS.items():
|
||||
if not keywords:
|
||||
continue
|
||||
score = sum(2 for kw in keywords if kw in text)
|
||||
# Boost if a matching label exists
|
||||
for label in labels:
|
||||
if any(kw in label for kw in keywords):
|
||||
score += 3
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_role = role
|
||||
|
||||
return best_role
|
||||
|
||||
|
||||
def compute_priority(issue: dict) -> int:
|
||||
"""Compute issue priority from keywords."""
|
||||
title = (issue.get("title", "") or "").lower()
|
||||
body = (issue.get("body", "") or "").lower()
|
||||
text = f"{title} {body}"
|
||||
return sum(v for k, v in PRIORITY_KEYWORDS.items() if k in text)
|
||||
|
||||
|
||||
def score_agent_for_issue(agent: dict, role: str, wolf_scores: dict, priority: int) -> float:
|
||||
"""Score how well an agent matches an issue. Higher is better."""
|
||||
score = 0.0
|
||||
|
||||
# Primary: role match
|
||||
agent_role = agent.get("role", "unknown")
|
||||
if agent_role == role:
|
||||
score += 10.0
|
||||
elif agent_role == "small-tasks" and role in ("issue-triage", "on-request-queries"):
|
||||
score += 6.0
|
||||
elif agent_role == "triage-routing" and role in ("issue-triage", "triage-routing"):
|
||||
score += 8.0
|
||||
elif agent_role == "code-generation" and role in ("code-review", "devops"):
|
||||
score += 4.0
|
||||
|
||||
# Wolf quality bonus
|
||||
model = agent.get("model", "")
|
||||
wolf_entry = None
|
||||
for wm, ws in wolf_scores.items():
|
||||
if model and model.lower() in wm.lower():
|
||||
wolf_entry = ws
|
||||
break
|
||||
if wolf_entry and wolf_entry.get("success"):
|
||||
score += wolf_entry.get("total", 0) * 3.0
|
||||
|
||||
# Cost efficiency: prefer free/cheap for low priority
|
||||
tier = agent.get("tier", "unknown")
|
||||
tier_idx = TIER_ORDER.index(tier) if tier in TIER_ORDER else 3
|
||||
if priority <= 1 and tier in ("free", "cheap"):
|
||||
score += 4.0
|
||||
elif priority >= 3 and tier in ("prepaid",):
|
||||
score += 3.0
|
||||
else:
|
||||
score += (3 - tier_idx) * 1.0
|
||||
|
||||
# Activity bonus
|
||||
if agent.get("active", False):
|
||||
score += 2.0
|
||||
|
||||
# Repo familiarity bonus: more repos slightly better
|
||||
repo_count = agent.get("repo_count", 0)
|
||||
score += min(repo_count * 0.2, 2.0)
|
||||
|
||||
return round(score, 3)
|
||||
|
||||
|
||||
def find_best_agent(agents: List[dict], role: str, wolf_scores: dict, priority: int,
|
||||
exclude: Optional[List[str]] = None) -> Optional[dict]:
|
||||
"""Find the best agent for the given role and priority."""
|
||||
exclude = exclude or []
|
||||
candidates = []
|
||||
for agent in agents:
|
||||
if agent.get("name") in exclude:
|
||||
continue
|
||||
if not agent.get("active", False):
|
||||
continue
|
||||
s = score_agent_for_issue(agent, role, wolf_scores, priority)
|
||||
candidates.append((s, agent))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
return candidates[0][1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def dispatch_assignment(api: GiteaAPI, issue: dict, agent: dict, dry_run: bool = False) -> dict:
|
||||
"""Assign an issue to an agent and optionally post a comment."""
|
||||
owner = ORG_NAME
|
||||
repo = issue.get("repository", {}).get("name", "")
|
||||
|
||||
# Extract repo from issue repo_url if not in the repository key
|
||||
if not repo:
|
||||
repo_url = issue.get("repository_url", "")
|
||||
if repo_url:
|
||||
repo = repo_url.rstrip("/").split("/")[-1]
|
||||
|
||||
if not repo:
|
||||
return {"success": False, "error": "Cannot determine repository for issue"}
|
||||
|
||||
issue_num = issue.get("number")
|
||||
agent_name = agent.get("name", "unknown")
|
||||
|
||||
comment_body = (
|
||||
f"🤖 **Workforce Manager assigned this issue to: @{agent_name}**\n\n"
|
||||
f"- **Agent:** {agent_name}\n"
|
||||
f"- **Model:** {agent.get('model', 'unknown')}\n"
|
||||
f"- **Role:** {agent.get('role', 'unknown')}\n"
|
||||
f"- **Tier:** {agent.get('tier', 'unknown')}\n"
|
||||
f"- **Assigned at:** {datetime.now(timezone.utc).isoformat()}\n\n"
|
||||
f"*Automated assignment by Workforce Manager (Epic #204)*"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"success": True,
|
||||
"dry_run": True,
|
||||
"repo": repo,
|
||||
"issue_number": issue_num,
|
||||
"assignee": agent_name,
|
||||
"comment": comment_body,
|
||||
}
|
||||
|
||||
try:
|
||||
api.assign_issue(owner, repo, issue_num, agent_name)
|
||||
api.add_issue_comment(owner, repo, issue_num, comment_body)
|
||||
return {
|
||||
"success": True,
|
||||
"repo": repo,
|
||||
"issue_number": issue_num,
|
||||
"issue_title": issue.get("title", ""),
|
||||
"assignee": agent_name,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"repo": repo,
|
||||
"issue_number": issue_num,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State Tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def update_agent_stats(state: dict, result: dict) -> None:
|
||||
"""Update per-agent success tracking."""
|
||||
agent_name = result.get("assignee", "unknown")
|
||||
if "agent_stats" not in state:
|
||||
state["agent_stats"] = {}
|
||||
if agent_name not in state["agent_stats"]:
|
||||
state["agent_stats"][agent_name] = {
|
||||
"total_assigned": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"success_rate": 0.0,
|
||||
"last_assignment": None,
|
||||
"assigned_issues": [],
|
||||
}
|
||||
|
||||
stats = state["agent_stats"][agent_name]
|
||||
stats["total_assigned"] += 1
|
||||
stats["last_assignment"] = datetime.now(timezone.utc).isoformat()
|
||||
stats["assigned_issues"].append({
|
||||
"repo": result.get("repo", ""),
|
||||
"issue_number": result.get("issue_number"),
|
||||
"success": result.get("success", False),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
if result.get("success"):
|
||||
stats["successful"] += 1
|
||||
else:
|
||||
stats["failed"] += 1
|
||||
|
||||
total = stats["successful"] + stats["failed"]
|
||||
stats["success_rate"] = round(stats["successful"] / total, 3) if total > 0 else 0.0
|
||||
|
||||
|
||||
def print_status(state: dict, agents: List[dict], issues_count: int) -> None:
|
||||
"""Print workforce status."""
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Workforce Manager Status - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
# Fleet summary
|
||||
active = [a for a in agents if a.get("active")]
|
||||
print(f"\nFleet: {len(active)} active agents, {len(agents)} total")
|
||||
tier_counts = {}
|
||||
for a in active:
|
||||
t = a.get("tier", "unknown")
|
||||
tier_counts[t] = tier_counts.get(t, 0) + 1
|
||||
for t, c in sorted(tier_counts.items()):
|
||||
print(f" {t}: {c} agents")
|
||||
|
||||
# Agent scores
|
||||
wolf = load_wolf_scores()
|
||||
print(f"\nAgent Details:")
|
||||
print(f" {'Name':<25} {'Model':<30} {'Role':<18} {'Tier':<10}")
|
||||
for a in agents:
|
||||
if not a.get("active"):
|
||||
continue
|
||||
stats = state.get("agent_stats", {}).get(a["name"], {})
|
||||
rate = stats.get("success_rate", 0.0)
|
||||
total = stats.get("total_assigned", 0)
|
||||
wolf_badge = ""
|
||||
for wm, ws in wolf.items():
|
||||
if a["model"] and a["model"].lower() in wm.lower() and ws.get("success"):
|
||||
wolf_badge = f"[wolf:{ws['total']}]"
|
||||
break
|
||||
name_str = f"{a['name']} {wolf_badge}"
|
||||
if total > 0:
|
||||
name_str += f" (s/r: {rate}, n={total})"
|
||||
print(f" {name_str:<45} {a.get('role', 'unknown'):<18} {a.get('tier', '?'):<10}")
|
||||
|
||||
print(f"\nOpen Issues: {issues_count}")
|
||||
print(f"Assignments Made: {len(state.get('assignments', []))}")
|
||||
if state.get("last_run"):
|
||||
print(f"Last Run: {state['last_run']}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Workforce Manager - Assign Gitea issues to AI agents")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Show assignments without dispatching")
|
||||
parser.add_argument("--status", action="store_true", help="Show workforce status only")
|
||||
parser.add_argument("--cron", action="store_true", help="Run silently for cron scheduling")
|
||||
parser.add_argument("--label", type=str, help="Only process issues with this label")
|
||||
parser.add_argument("--max-issues", type=int, default=100, help="Max issues to process per run")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
if args.cron:
|
||||
logging.basicConfig(level=logging.WARNING, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
else:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
|
||||
logging.info("Workforce Manager starting")
|
||||
|
||||
# Load data
|
||||
agents = load_fleet_routing()
|
||||
if not agents:
|
||||
logging.error("No agents found in fleet-routing.json")
|
||||
return 1
|
||||
logging.info("Loaded %d agents from fleet routing", len(agents))
|
||||
|
||||
wolf_scores = load_wolf_scores()
|
||||
if wolf_scores:
|
||||
logging.info("Loaded %d model scores from Wolf results", len(wolf_scores))
|
||||
|
||||
state = load_workforce_state()
|
||||
|
||||
# Load Gitea token
|
||||
if GITEA_TOKEN_PATH.exists():
|
||||
token = GITEA_TOKEN_PATH.read_text().strip()
|
||||
else:
|
||||
logging.error("Gitea token not found at %s", GITEA_TOKEN_PATH)
|
||||
return 1
|
||||
|
||||
api = GiteaAPI(token)
|
||||
|
||||
# Status-only mode
|
||||
if args.status:
|
||||
# Quick open issue count
|
||||
repos = api.get_org_repos(ORG_NAME)
|
||||
total = sum(r.get("open_issues_count", 0) for r in repos)
|
||||
print_status(state, agents, total)
|
||||
return 0
|
||||
|
||||
# Fetch open issues
|
||||
if not args.cron:
|
||||
print(f"Scanning open issues across {ORG_NAME} repos...")
|
||||
|
||||
issues = api.get_all_open_issues(ORG_NAME)
|
||||
|
||||
# Filter by label
|
||||
if args.label:
|
||||
issues = [
|
||||
i for i in issues
|
||||
if any(args.label in (l.get("name", "") or "").lower() for l in i.get("labels", []))
|
||||
]
|
||||
|
||||
if args.label:
|
||||
logging.info("Filtered to %d issues with label '%s'", len(issues), args.label)
|
||||
else:
|
||||
logging.info("Found %d open issues", len(issues))
|
||||
|
||||
# Skip issues already assigned
|
||||
already_assigned_nums = set()
|
||||
for a in state.get("assignments", []):
|
||||
already_assigned_nums.add((a.get("repo"), a.get("issue_number")))
|
||||
|
||||
issues = [
|
||||
i for i in issues
|
||||
if not i.get("assignee") and
|
||||
not (i.get("repository", {}).get("name"), i.get("number")) in already_assigned_nums
|
||||
]
|
||||
logging.info("%d unassigned issues to process", len(issues))
|
||||
|
||||
# Sort by priority
|
||||
issues_with_priority = [(compute_priority(i), i) for i in issues]
|
||||
issues_with_priority.sort(key=lambda x: x[0], reverse=True)
|
||||
issues = [i for _, i in issues_with_priority[:args.max_issues]]
|
||||
|
||||
# Assign issues
|
||||
assignments = []
|
||||
agent_exclusions: Dict[str, List[str]] = {} # repo -> list of assigned agents per run
|
||||
global_exclusions: List[str] = [] # agents already at capacity per run
|
||||
max_per_agent_per_run = 5
|
||||
|
||||
for issue in issues:
|
||||
role = classify_issue(issue)
|
||||
priority = compute_priority(issue)
|
||||
repo = issue.get("repository", {}).get("name", "")
|
||||
|
||||
# Avoid assigning same agent twice to same repo in one run
|
||||
repo_excluded = agent_exclusions.get(repo, [])
|
||||
|
||||
# Also exclude agents already at assignment cap
|
||||
cap_excluded = [
|
||||
name for name, stats in state.get("agent_stats", {}).items()
|
||||
if stats.get("total_assigned", 0) > max_per_agent_per_run
|
||||
]
|
||||
|
||||
excluded = list(set(repo_excluded + global_exclusions + cap_excluded))
|
||||
|
||||
agent = find_best_agent(agents, role, wolf_scores, priority, exclude=excluded)
|
||||
if not agent:
|
||||
# Relax exclusions if no agent found
|
||||
agent = find_best_agent(agents, role, wolf_scores, priority, exclude=[])
|
||||
if not agent:
|
||||
logging.warning("No suitable agent for issue #%d: %s (role=%s)",
|
||||
issue.get("number"), issue.get("title", ""), role)
|
||||
continue
|
||||
|
||||
result = dispatch_assignment(api, issue, agent, dry_run=args.dry_run)
|
||||
assignments.append(result)
|
||||
update_agent_stats(state, result)
|
||||
|
||||
# Track per-repo exclusions
|
||||
if repo not in agent_exclusions:
|
||||
agent_exclusions[repo] = []
|
||||
agent_exclusions[repo].append(agent["name"])
|
||||
|
||||
if args.dry_run:
|
||||
print(f" [DRY] #{issue['number']}: {issue.get('title','')[:60]} → @{agent['name']} ({role}, p={priority})")
|
||||
else:
|
||||
status_str = "OK" if result.get("success") else "FAIL"
|
||||
print(f" [{status_str}] #{issue['number']}: {issue.get('title','')[:60]} → @{agent['name']} ({role}, p={priority})")
|
||||
|
||||
# Save state
|
||||
state["assignments"].extend([{
|
||||
"repo": a.get("repo"),
|
||||
"issue_number": a.get("issue_number"),
|
||||
"assignee": a.get("assignee"),
|
||||
"success": a.get("success", False),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
} for a in assignments])
|
||||
state["last_run"] = datetime.now(timezone.utc).isoformat()
|
||||
save_workforce_state(state)
|
||||
|
||||
# Summary
|
||||
ok = sum(1 for a in assignments if a.get("success"))
|
||||
fail = len(assignments) - ok
|
||||
logging.info("Done: %d assigned, %d succeeded, %d failed", len(assignments), ok, fail)
|
||||
|
||||
if not args.cron:
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Summary: {len(assignments)} assignments, {ok} OK, {fail} failed")
|
||||
# Show agent stats
|
||||
for name, stats in state.get("agent_stats", {}).items():
|
||||
if stats.get("total_assigned", 0) > 0:
|
||||
print(f" @{name}: {stats['successful']}/{stats['total_assigned']} ({stats.get('success_rate', 0):.0%} success)")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user