Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions factory/agents/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import yaml

from factory.ace.injector import inject_playbook
from factory.ace.paths import DEFAULTS_DIR as _PLAYBOOKS_DIR
from factory.agents.runner import _PROMPTS_DIR

_AGENTS_YML = Path(__file__).parent / "agents.yml"
Expand All @@ -17,7 +19,7 @@
@dataclass(frozen=True)
class AgentMeta:
description: str
model: str
model: str # from agents.yml; not emitted in frontmatter (subagents inherit parent model)
tools: list[str]


Expand Down Expand Up @@ -52,8 +54,15 @@ def generate_agent_content(role: str) -> str:

meta = config[role]
prompt = (_PROMPTS_DIR / f"{role}.md").read_text()
# Only inject factory-default playbooks (not user-local ~/.factory/playbooks/)
# so that sync_agents.py output is deterministic across machines.
playbook_path = _PLAYBOOKS_DIR / f"{role}.md"
if playbook_path.exists():
playbook = playbook_path.read_text().strip()
if playbook:
prompt = inject_playbook(prompt, playbook)
frontmatter = yaml.dump(
{"name": role, "description": meta.description, "model": meta.model, "tools": meta.tools},
{"name": role, "description": meta.description, "tools": meta.tools},
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
Expand Down
23 changes: 23 additions & 0 deletions factory/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,21 @@ def cmd_explain(args: argparse.Namespace) -> int:
return 0


def cmd_emit(args: argparse.Namespace) -> int:
from factory.events import emit_event

project_path = Path(args.project).resolve()
data: dict = {}
if args.data:
try:
data = json.loads(args.data)
except json.JSONDecodeError as e:
print(f"Error: --data is not valid JSON: {e}", file=sys.stderr)
return 1
emit_event(project_path, args.event_type, agent=args.agent, data=data)
return 0


def cmd_vault_init(args: argparse.Namespace) -> int:
from factory.obsidian.notes import init_vault

Expand Down Expand Up @@ -2357,6 +2372,13 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--port", type=int, default=8420, help="Server port (default: 8420)")
p.add_argument("--host", default="0.0.0.0", help="Server host (default: 0.0.0.0)")

# emit — emit a structured event to .factory/events.jsonl
p = sub.add_parser("emit", help="Emit a structured event to .factory/events.jsonl")
p.add_argument("event_type", help="Event type (e.g. agent.started, agent.completed)")
p.add_argument("--agent", default=None, help="Agent role name")
p.add_argument("--project", default=".", help="Project path")
p.add_argument("--data", default=None, help="JSON string of additional event data")

# agent — invoke a specialist agent directly
p = sub.add_parser("agent", help="Invoke a specialist agent with a task")
p.add_argument("role", choices=["researcher", "strategist", "builder", "reviewer",
Expand Down Expand Up @@ -2552,6 +2574,7 @@ def main(argv: list[str] | None = None) -> int:
"install": cmd_install,
"serve-mcp": cmd_serve_mcp,
"dashboard": cmd_dashboard,
"emit": cmd_emit,
"agent": cmd_agent,
"ceo": cmd_ceo,
"run": cmd_run,
Expand Down
19 changes: 13 additions & 6 deletions skills/pipeline-subagents/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,23 @@ Process steps in topological order:

1. **Identify next batch** — steps whose dependencies are all complete
2. **Build prompts** — incorporate output from prior steps (agent results are returned directly)
3. **Invoke agents:**
- Single step: one Agent call
- Parallel batch: multiple Agent calls in same message
- Archival: can use `run_in_background: true`
3. **Emit start event** and **invoke agents:**
```bash
factory emit agent.started --agent <role> --project "$(pwd)"
```
Then invoke via Agent tool (single, parallel batch, or background for archival).
4. **Read results** — Agent tool returns subagent output directly
5. **Apply gate rule:**
5. **Persist review and emit completion:**
```bash
mkdir -p .factory/reviews
factory emit agent.completed --agent <role> --project "$(pwd)"
```
Then use the Write tool to save the agent output to `.factory/reviews/<role>-latest.md`
6. **Apply gate rule:**
- **PROCEED**: Move to next step
- **REDIRECT**: Re-invoke with corrections (max 2 per step)
- **ABORT**: Skip downstream steps, jump to summary
6. **Repeat** until done
7. **Repeat** until done

### Error Recovery

Expand Down
15 changes: 15 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ def test_finalize_with_scores(self):
def test_no_command_returns_1(self):
assert main([]) == 1

def test_emit_subcommand(self):
parser = build_parser()
args = parser.parse_args(["emit", "agent.started", "--agent", "researcher", "--project", "/p"])
assert args.command == "emit"
assert args.event_type == "agent.started"
assert args.agent == "researcher"
assert args.project == "/p"

def test_emit_defaults(self):
parser = build_parser()
args = parser.parse_args(["emit", "cycle.started"])
assert args.agent is None
assert args.project == "."
assert args.data is None

def test_ceo_mode_interactive(self):
parser = build_parser()
args = parser.parse_args(["ceo", "distributed eval runner", "--mode", "interactive"])
Expand Down
75 changes: 75 additions & 0 deletions tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
from argparse import Namespace
from datetime import datetime, timezone

from factory.events import discover_factory_projects, emit_event, load_events
Expand Down Expand Up @@ -155,3 +156,77 @@ def test_returns_sorted(self, tmp_path):
projects = discover_factory_projects(tmp_path)
assert projects[0].name == "alpha"
assert projects[1].name == "zeta"


class TestCmdEmit:
def test_cmd_emit_writes_event(self, tmp_path):
from factory.cli import cmd_emit

(tmp_path / ".factory").mkdir()
args = Namespace(
event_type="agent.started",
agent="researcher",
project=str(tmp_path),
data=None,
)
rc = cmd_emit(args)
assert rc == 0

events_file = tmp_path / ".factory" / "events.jsonl"
assert events_file.exists()

event = json.loads(events_file.read_text().strip())
assert event["type"] == "agent.started"
assert event["agent"] == "researcher"

def test_cmd_emit_with_data(self, tmp_path):
from factory.cli import cmd_emit

(tmp_path / ".factory").mkdir()
args = Namespace(
event_type="agent.completed",
agent="builder",
project=str(tmp_path),
data='{"task": "fix bug", "duration": 42}',
)
rc = cmd_emit(args)
assert rc == 0

events_file = tmp_path / ".factory" / "events.jsonl"
event = json.loads(events_file.read_text().strip())
assert event["type"] == "agent.completed"
assert event["agent"] == "builder"
assert event["data"]["task"] == "fix bug"
assert event["data"]["duration"] == 42

def test_cmd_emit_without_agent(self, tmp_path):
from factory.cli import cmd_emit

(tmp_path / ".factory").mkdir()
args = Namespace(
event_type="cycle.started",
agent=None,
project=str(tmp_path),
data='{"cycle": 1}',
)
rc = cmd_emit(args)
assert rc == 0

events_file = tmp_path / ".factory" / "events.jsonl"
event = json.loads(events_file.read_text().strip())
assert event["type"] == "cycle.started"
assert event["agent"] is None
assert event["data"]["cycle"] == 1

def test_cmd_emit_rejects_invalid_json(self, tmp_path):
from factory.cli import cmd_emit

(tmp_path / ".factory").mkdir()
args = Namespace(
event_type="agent.started",
agent="researcher",
project=str(tmp_path),
data="not valid json",
)
rc = cmd_emit(args)
assert rc == 1
6 changes: 3 additions & 3 deletions tests/test_plugin_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ def test_frontmatter_has_required_fields(self):
fm = _parse_frontmatter(content)
assert "name" in fm, f"{role}: missing name"
assert "description" in fm, f"{role}: missing description"
assert "model" in fm, f"{role}: missing model"
assert "tools" in fm, f"{role}: missing tools"

def test_frontmatter_name_matches_role(self):
Expand All @@ -108,8 +107,9 @@ def test_preserves_prompt_content(self):
for role in ALL_ROLES:
source = (_PROMPTS_DIR / f"{role}.md").read_text()
generated = generate_agent_content(role)
assert generated.endswith(source), (
f"{role}: generated file does not end with source prompt"
# Generated content may have playbook injected, so check source is in it
assert source in generated, (
f"{role}: generated file does not include source prompt"
)

def test_unknown_role_raises(self):
Expand Down
Loading