Create Stackchain Lab agent collaboration loop
This commit is contained in:
commit
fdb454414d
24
.gitea/issue_template/task.md
Normal file
24
.gitea/issue_template/task.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
name: Lab Loop Task
|
||||
title: "[Task] "
|
||||
about: A bounded Timmy/Vincent collaboration ticket
|
||||
labels: "state:ready,priority:P1"
|
||||
---
|
||||
|
||||
## Outcome
|
||||
|
||||
What must exist or be decided?
|
||||
|
||||
## Owner
|
||||
|
||||
Choose exactly one: `agent:timmy` or `agent:vincent`.
|
||||
|
||||
## Acceptance evidence
|
||||
|
||||
- [ ] Artifact, commit, source, or test result is linked
|
||||
- [ ] The evidence has been independently verified
|
||||
- [ ] Next state is review, blocked, or an explicit single-agent handoff
|
||||
|
||||
## Constraints
|
||||
|
||||
List privacy, hardware, licensing, time, or deployment limits.
|
||||
13
AGENTS.md
Normal file
13
AGENTS.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Agent instructions
|
||||
|
||||
Follow `PROTOCOL.md`.
|
||||
|
||||
- Identify yourself through `LAB_AGENT`; supported initial identities are `timmy` and `vincent`.
|
||||
- Process at most one ready issue per cycle.
|
||||
- Do not post status updates, greetings, thanks, or automated acknowledgments.
|
||||
- Claim before touching work.
|
||||
- Return verified artifacts, decisions, or a single blocker.
|
||||
- Handoff to exactly one agent. Never create reciprocal automatic handoffs.
|
||||
- Do not close issues automatically; move completed work to `state:review`.
|
||||
- Keep tokens in the host environment under `GITEA_TOKEN`.
|
||||
- Never force-push or mutate another agent's active branch.
|
||||
32
HOST_SETUP.md
Normal file
32
HOST_SETUP.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Independent host connection
|
||||
|
||||
Each Hermes host uses its own Forge account and scoped token. Transfer tokens privately; never place them in Telegram groups or this repository.
|
||||
|
||||
```bash
|
||||
git clone https://forge.alexanderwhitestone.com/git/stackchain/stackchain-lab-loop.git
|
||||
cd stackchain-lab-loop
|
||||
export LAB_AGENT=vincent # or timmy
|
||||
export GITEA_TOKEN='<host-local secret>'
|
||||
python3 scripts/lab_loop.py next
|
||||
```
|
||||
|
||||
A worker cycle processes no more than one issue:
|
||||
|
||||
1. Run `next`.
|
||||
2. If the result is `idle`, exit silently.
|
||||
3. Claim the returned issue:
|
||||
```bash
|
||||
python3 scripts/lab_loop.py claim ISSUE_NUMBER
|
||||
```
|
||||
4. Read the complete issue and comments, perform the bounded work, and verify the result.
|
||||
5. Finish for human review:
|
||||
```bash
|
||||
python3 scripts/lab_loop.py finish ISSUE_NUMBER --file /tmp/review-comment.md
|
||||
```
|
||||
or hand off once:
|
||||
```bash
|
||||
python3 scripts/lab_loop.py handoff ISSUE_NUMBER --to timmy --file /tmp/handoff.md
|
||||
```
|
||||
6. Do not post Telegram status. Gitea holds the durable conversation.
|
||||
|
||||
Recommended Hermes cron behavior: run every 20–30 minutes, work only `state:ready` issues assigned to this agent, and deliver locally unless an issue carries `human-gate`.
|
||||
52
PROTOCOL.md
Normal file
52
PROTOCOL.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Stackchain Lab Loop
|
||||
|
||||
This repository is the durable conversation between independently hosted agents.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Gitea issues are the queue and issue comments are the thread.
|
||||
2. An agent acts only on an open issue carrying both `agent:<name>` and `state:ready`.
|
||||
3. Claim before work. A claim changes `state:ready` to `state:claimed` and leaves one `[CLAIM]` receipt.
|
||||
4. Post only substantive outputs: a decision, evidence, artifact, blocker, or handoff. Never post progress chatter or acknowledgments.
|
||||
5. A handoff names exactly one next agent and changes the issue back to `state:ready`.
|
||||
6. Finished work enters `state:review`. Humans approve closure or request another handoff.
|
||||
7. One issue per concern. Branches use `<agent>/<issue>-<slug>`; no force pushes.
|
||||
8. Claims must include reproducible evidence. Links must be fetched before being reported as working.
|
||||
9. Tokens stay on each agent's host. Never commit credentials or paste them into issue threads.
|
||||
10. Three consecutive failures move the issue to `state:blocked` for human review.
|
||||
|
||||
## Labels
|
||||
|
||||
- `agent:timmy`, `agent:vincent`: exclusive next owner
|
||||
- `state:ready`, `state:claimed`, `state:review`, `state:blocked`: lifecycle
|
||||
- `priority:P0`, `priority:P1`, `priority:P2`: urgency
|
||||
- `kind:build`, `kind:research`, `kind:creative`, `kind:ops`: work type
|
||||
- `human-gate`: explicit Alexander/operator decision required
|
||||
|
||||
## Comment forms
|
||||
|
||||
```text
|
||||
[CLAIM] agent=timmy
|
||||
|
||||
[DECISION]
|
||||
<decision and reason>
|
||||
|
||||
[EVIDENCE]
|
||||
<verified artifact, test output, commit, or source>
|
||||
|
||||
[HANDOFF] to=vincent
|
||||
<one concrete question or task>
|
||||
|
||||
[BLOCKED]
|
||||
<one blocker and the exact human action required>
|
||||
```
|
||||
|
||||
## Loop
|
||||
|
||||
`READY → CLAIMED → REVIEW`
|
||||
|
||||
or
|
||||
|
||||
`READY → CLAIMED → HANDOFF(new agent) → READY`
|
||||
|
||||
The agent utility is `scripts/lab_loop.py`. Run `python3 scripts/lab_loop.py --help` for commands.
|
||||
7
pyproject.toml
Normal file
7
pyproject.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[project]
|
||||
name = "stackchain-lab-loop"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
BIN
scripts/__pycache__/lab_loop.cpython-311.pyc
Normal file
BIN
scripts/__pycache__/lab_loop.cpython-311.pyc
Normal file
Binary file not shown.
242
scripts/lab_loop.py
Normal file
242
scripts/lab_loop.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Small, dependency-free client for the Stackchain Lab Gitea issue loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_BASE = "https://forge.alexanderwhitestone.com/git/api/v1"
|
||||
DEFAULT_REPO = "stackchain/stackchain-lab-loop"
|
||||
AGENTS = {"timmy", "vincent"}
|
||||
STATES = {"ready", "claimed", "review", "blocked"}
|
||||
|
||||
|
||||
class LoopError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Gitea:
|
||||
def __init__(self, base: str, repo: str, token: str):
|
||||
self.base = base.rstrip("/")
|
||||
self.repo = repo.strip("/")
|
||||
self.token = token
|
||||
|
||||
def request(self, method: str, path: str, payload: Any | None = None) -> Any:
|
||||
data = None if payload is None else json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{self.base}{path}",
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"token {self.token}",
|
||||
"User-Agent": "stackchain-lab-loop/0.1",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as response:
|
||||
raw = response.read()
|
||||
return json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode(errors="replace")
|
||||
raise LoopError(f"Gitea {method} {path} failed: HTTP {exc.code}: {detail}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise LoopError(f"Gitea {method} {path} failed: {exc.reason}") from exc
|
||||
|
||||
def labels(self) -> dict[str, int]:
|
||||
rows = self.request("GET", f"/repos/{self.repo}/labels?limit=100")
|
||||
return {row["name"]: int(row["id"]) for row in rows}
|
||||
|
||||
def issue(self, number: int) -> dict[str, Any]:
|
||||
return self.request("GET", f"/repos/{self.repo}/issues/{number}")
|
||||
|
||||
def issues(self) -> list[dict[str, Any]]:
|
||||
return self.request("GET", f"/repos/{self.repo}/issues?state=open&type=issues&limit=100")
|
||||
|
||||
def set_labels(self, number: int, names: set[str]) -> None:
|
||||
label_map = self.labels()
|
||||
missing = sorted(names - label_map.keys())
|
||||
if missing:
|
||||
raise LoopError(f"Missing repository labels: {', '.join(missing)}")
|
||||
self.request(
|
||||
"PATCH",
|
||||
f"/repos/{self.repo}/issues/{number}",
|
||||
{"labels": [label_map[name] for name in sorted(names)]},
|
||||
)
|
||||
|
||||
def comment(self, number: int, body: str) -> None:
|
||||
if not body.strip():
|
||||
raise LoopError("Refusing to post an empty comment")
|
||||
self.request("POST", f"/repos/{self.repo}/issues/{number}/comments", {"body": body.strip()})
|
||||
|
||||
|
||||
def issue_label_names(issue: dict[str, Any]) -> set[str]:
|
||||
return {label["name"] for label in issue.get("labels", [])}
|
||||
|
||||
|
||||
def transition(names: set[str], *, agent: str | None = None, state: str | None = None) -> set[str]:
|
||||
result = {name for name in names if not name.startswith("agent:") and not name.startswith("state:")}
|
||||
if agent:
|
||||
if agent not in AGENTS:
|
||||
raise LoopError(f"Unsupported agent: {agent}")
|
||||
result.add(f"agent:{agent}")
|
||||
if state:
|
||||
if state not in STATES:
|
||||
raise LoopError(f"Unsupported state: {state}")
|
||||
result.add(f"state:{state}")
|
||||
return result
|
||||
|
||||
|
||||
def validate_owner(issue: dict[str, Any], agent: str, required_state: str) -> set[str]:
|
||||
names = issue_label_names(issue)
|
||||
if f"agent:{agent}" not in names:
|
||||
raise LoopError(f"Issue #{issue['number']} is not owned by agent:{agent}")
|
||||
if f"state:{required_state}" not in names:
|
||||
raise LoopError(f"Issue #{issue['number']} is not state:{required_state}")
|
||||
return names
|
||||
|
||||
|
||||
def read_body(args: argparse.Namespace, default: str = "") -> str:
|
||||
if getattr(args, "file", None):
|
||||
return Path(args.file).read_text()
|
||||
if getattr(args, "body", None):
|
||||
return args.body
|
||||
return default
|
||||
|
||||
|
||||
def cmd_next(api: Gitea, agent: str, _args: argparse.Namespace) -> int:
|
||||
candidates = []
|
||||
for issue in api.issues():
|
||||
labels = issue_label_names(issue)
|
||||
if f"agent:{agent}" in labels and "state:ready" in labels:
|
||||
candidates.append(issue)
|
||||
candidates.sort(key=lambda row: int(row["number"]))
|
||||
if not candidates:
|
||||
print(json.dumps({"status": "idle", "agent": agent}))
|
||||
return 3
|
||||
issue = candidates[0]
|
||||
print(json.dumps({
|
||||
"status": "ready",
|
||||
"agent": agent,
|
||||
"number": issue["number"],
|
||||
"title": issue["title"],
|
||||
"url": issue["html_url"],
|
||||
"labels": sorted(issue_label_names(issue)),
|
||||
"body": issue.get("body") or "",
|
||||
}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_claim(api: Gitea, agent: str, args: argparse.Namespace) -> int:
|
||||
issue = api.issue(args.number)
|
||||
names = validate_owner(issue, agent, "ready")
|
||||
api.set_labels(args.number, transition(names, agent=agent, state="claimed"))
|
||||
api.comment(args.number, f"[CLAIM] agent={agent}")
|
||||
verified = api.issue(args.number)
|
||||
validate_owner(verified, agent, "claimed")
|
||||
print(json.dumps({"status": "claimed", "number": args.number, "agent": agent}))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_comment(api: Gitea, agent: str, args: argparse.Namespace) -> int:
|
||||
issue = api.issue(args.number)
|
||||
validate_owner(issue, agent, "claimed")
|
||||
api.comment(args.number, read_body(args))
|
||||
print(json.dumps({"status": "commented", "number": args.number, "agent": agent}))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_handoff(api: Gitea, agent: str, args: argparse.Namespace) -> int:
|
||||
if args.to == agent:
|
||||
raise LoopError("Refusing a handoff to the same agent")
|
||||
issue = api.issue(args.number)
|
||||
names = validate_owner(issue, agent, "claimed")
|
||||
body = read_body(args, f"Continue the bounded task in issue #{args.number}.")
|
||||
api.comment(args.number, f"[HANDOFF] from={agent} to={args.to}\n\n{body.strip()}")
|
||||
api.set_labels(args.number, transition(names, agent=args.to, state="ready"))
|
||||
verified = api.issue(args.number)
|
||||
validate_owner(verified, args.to, "ready")
|
||||
print(json.dumps({"status": "handed_off", "number": args.number, "from": agent, "to": args.to}))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_finish(api: Gitea, agent: str, args: argparse.Namespace) -> int:
|
||||
issue = api.issue(args.number)
|
||||
names = validate_owner(issue, agent, "claimed")
|
||||
body = read_body(args)
|
||||
if body.strip():
|
||||
api.comment(args.number, f"[REVIEW] agent={agent}\n\n{body.strip()}")
|
||||
api.set_labels(args.number, transition(names, agent=agent, state="review"))
|
||||
verified = api.issue(args.number)
|
||||
validate_owner(verified, agent, "review")
|
||||
print(json.dumps({"status": "review", "number": args.number, "agent": agent}))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_block(api: Gitea, agent: str, args: argparse.Namespace) -> int:
|
||||
issue = api.issue(args.number)
|
||||
names = validate_owner(issue, agent, "claimed")
|
||||
body = read_body(args)
|
||||
api.comment(args.number, f"[BLOCKED] agent={agent}\n\n{body.strip()}")
|
||||
api.set_labels(args.number, transition(names, agent=agent, state="blocked"))
|
||||
print(json.dumps({"status": "blocked", "number": args.number, "agent": agent}))
|
||||
return 0
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--base", default=os.getenv("GITEA_BASE", DEFAULT_BASE))
|
||||
p.add_argument("--repo", default=os.getenv("LAB_REPO", DEFAULT_REPO))
|
||||
p.add_argument("--agent", default=os.getenv("LAB_AGENT", ""))
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
sub.add_parser("next")
|
||||
for name in ("claim", "comment", "finish", "block"):
|
||||
cmd = sub.add_parser(name)
|
||||
cmd.add_argument("number", type=int)
|
||||
if name != "claim":
|
||||
source = cmd.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--body")
|
||||
source.add_argument("--file")
|
||||
handoff = sub.add_parser("handoff")
|
||||
handoff.add_argument("number", type=int)
|
||||
handoff.add_argument("--to", required=True, choices=sorted(AGENTS))
|
||||
source = handoff.add_mutually_exclusive_group()
|
||||
source.add_argument("--body")
|
||||
source.add_argument("--file")
|
||||
return p
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
if args.agent not in AGENTS:
|
||||
raise LoopError("Set LAB_AGENT to timmy or vincent, or pass --agent")
|
||||
token = os.getenv("GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise LoopError("GITEA_TOKEN is required")
|
||||
api = Gitea(args.base, args.repo, token)
|
||||
handlers = {
|
||||
"next": cmd_next,
|
||||
"claim": cmd_claim,
|
||||
"comment": cmd_comment,
|
||||
"handoff": cmd_handoff,
|
||||
"finish": cmd_finish,
|
||||
"block": cmd_block,
|
||||
}
|
||||
return handlers[args.command](api, args.agent, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except LoopError as exc:
|
||||
print(json.dumps({"status": "error", "error": str(exc)}), file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
BIN
tests/__pycache__/test_lab_loop.cpython-311.pyc
Normal file
BIN
tests/__pycache__/test_lab_loop.cpython-311.pyc
Normal file
Binary file not shown.
38
tests/test_lab_loop.py
Normal file
38
tests/test_lab_loop.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import unittest
|
||||
|
||||
from scripts.lab_loop import LoopError, issue_label_names, transition, validate_owner
|
||||
|
||||
|
||||
class LoopProtocolTests(unittest.TestCase):
|
||||
def test_transition_replaces_agent_and_state_but_preserves_kind_and_priority(self):
|
||||
labels = {"agent:timmy", "state:claimed", "priority:P1", "kind:build"}
|
||||
self.assertEqual(
|
||||
transition(labels, agent="vincent", state="ready"),
|
||||
{"agent:vincent", "state:ready", "priority:P1", "kind:build"},
|
||||
)
|
||||
|
||||
def test_same_agent_transition_is_idempotent(self):
|
||||
labels = {"agent:timmy", "state:ready", "priority:P0"}
|
||||
self.assertEqual(transition(labels, agent="timmy", state="ready"), labels)
|
||||
|
||||
def test_invalid_agent_fails_closed(self):
|
||||
with self.assertRaises(LoopError):
|
||||
transition(set(), agent="unknown", state="ready")
|
||||
|
||||
def test_validate_owner_requires_both_labels(self):
|
||||
issue = {
|
||||
"number": 7,
|
||||
"labels": [{"name": "agent:vincent"}, {"name": "state:claimed"}],
|
||||
}
|
||||
self.assertEqual(validate_owner(issue, "vincent", "claimed"), {"agent:vincent", "state:claimed"})
|
||||
with self.assertRaises(LoopError):
|
||||
validate_owner(issue, "timmy", "claimed")
|
||||
with self.assertRaises(LoopError):
|
||||
validate_owner(issue, "vincent", "ready")
|
||||
|
||||
def test_issue_label_names_handles_missing_labels(self):
|
||||
self.assertEqual(issue_label_names({}), set())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue
Block a user