Skip to content

Commit 23428d5

Browse files
author
dev-pd
committed
Idle seed-list loop: curated jobs grow B's memory; manual trigger + gated scheduler
1 parent 0e4f439 commit 23428d5

9 files changed

Lines changed: 276 additions & 7 deletions

File tree

backend/app/api/run.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from app.agents.agent_a import run_agent_a
1616
from app.agents.agent_b import run_agent_b
1717
from app.core.exceptions import ReplayMissError
18+
from app.jobs.seed_runner import SeedResult, run_seed_pass
1819
from app.memory.janitor import run_janitor
1920
from app.models import AgentRunResult
2021
from app.repositories.diary import DiaryRepository
@@ -75,3 +76,22 @@ async def run(req: RunRequest) -> RunResponse:
7576
raise HTTPException(status_code=502, detail=str(exc)) from exc
7677

7778
return RunResponse(a=a, b=b, note=None if probe.fetch_ok else probe.fetch_note)
79+
80+
81+
class SeedRequest(BaseModel):
82+
passes: int = 1
83+
84+
85+
@router.post("/seed", response_model=SeedResult)
86+
async def seed(req: SeedRequest) -> SeedResult:
87+
"""Work the curated seed jobs with learning on, then consolidate.
88+
89+
This is the idle behavior, run on demand. Live (real fetch + SERP + LLM), so it
90+
spends tokens per job — keep `passes` small.
91+
"""
92+
try:
93+
return await run_seed_pass(passes=req.passes, replay=False)
94+
except ReplayMissError as exc:
95+
raise HTTPException(status_code=409, detail=str(exc)) from exc
96+
except RuntimeError as exc:
97+
raise HTTPException(status_code=502, detail=str(exc)) from exc

backend/app/core/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ class Settings(BaseSettings):
2828
agent_model: str = "claude-opus-4-8"
2929
agent_max_tokens: int = 4096
3030

31+
# Idle seed loop: when enabled, the scheduler cycles the curated seed jobs to grow
32+
# B's memory between user jobs. Off by default — it spends tokens, so opt in.
33+
seed_idle_enabled: bool = False
34+
seed_idle_interval_seconds: int = 1800
35+
3136
@property
3237
def mode(self) -> str:
3338
"""Human-readable mode label exposed by the health check."""

backend/app/jobs/scheduler.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,31 @@
1-
"""Scheduler entrypoint (stub).
1+
"""Scheduler entrypoint.
22
3-
The real monitor scheduler arrives in Phase 4. Until then this only exists so
4-
`python -m app.jobs.scheduler` is a valid entrypoint; Compose runs it as an idle
5-
stub. Run directly, it logs once and idles.
3+
When `SEED_IDLE_ENABLED` is set, this cycles the curated seed jobs on a timer to
4+
grow Agent B's memory between user jobs (no web roaming — it only works the
5+
hand-picked seed list). Off by default, so it idles and spends nothing until you
6+
opt in.
67
"""
78

9+
import asyncio
810
import logging
911
import time
1012

13+
from app.core.config import settings
14+
from app.jobs.seed_runner import run_idle_loop
15+
1116
logging.basicConfig(level=logging.INFO)
1217
logger = logging.getLogger("scheduler")
1318

1419

1520
def main() -> None:
16-
logger.info("scheduler stub started; monitor tasks are wired in Phase 4. Idling.")
21+
if settings.seed_idle_enabled:
22+
logger.info(
23+
"seed idle loop on; cycling curated jobs every %ds",
24+
settings.seed_idle_interval_seconds,
25+
)
26+
asyncio.run(run_idle_loop(interval_seconds=settings.seed_idle_interval_seconds))
27+
return
28+
logger.info("scheduler idle; set SEED_IDLE_ENABLED=true to grow B's memory.")
1729
while True:
1830
time.sleep(3600)
1931

backend/app/jobs/seed_jobs.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Curated seed jobs — the work B does when idle.
2+
3+
B never roams the web (golden invariant: no autonomous page-hunting). Instead, when
4+
there is no user job, it cycles this small, hand-picked list of real (url, keyword)
5+
pairs — "given jobs", just pre-loaded by us. Working them with learning on, then
6+
consolidating, fills the playbook honestly without discovering new pages.
7+
8+
The pairs are grouped by page kind on purpose: several pages of the same kind let a
9+
`(page_kind, rec_type, target)` pattern recur past the promotion bar in a single
10+
pass. Every URL here was verified to fetch (200) with the scraper's headers.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
# (url, keyword) — verified fetchable, grouped by page kind for recurrence.
16+
SEED_JOBS: list[tuple[str, str]] = [
17+
# docs
18+
("https://developer.mozilla.org/en-US/docs/Web/CSS/flex", "css flexbox"),
19+
(
20+
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises",
21+
"javascript promises",
22+
),
23+
("https://docs.python.org/3/library/asyncio.html", "python asyncio"),
24+
("https://www.gnu.org/software/bash/manual/bash.html", "bash manual"),
25+
# articles
26+
("https://en.wikipedia.org/wiki/Espresso", "espresso"),
27+
("https://en.wikipedia.org/wiki/Machine_learning", "machine learning"),
28+
("https://simple.wikipedia.org/wiki/Coffee", "coffee"),
29+
# comparison
30+
(
31+
"https://en.wikipedia.org/wiki/Comparison_of_web_browsers",
32+
"web browser comparison",
33+
),
34+
(
35+
"https://en.wikipedia.org/wiki/Comparison_of_text_editors",
36+
"text editor comparison",
37+
),
38+
# recipe
39+
("https://en.wikibooks.org/wiki/Cookbook:Pancake", "pancake recipe"),
40+
("https://en.wikibooks.org/wiki/Cookbook:Guacamole", "guacamole recipe"),
41+
]

backend/app/jobs/seed_runner.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Run the curated seed jobs to grow B's memory when idle.
2+
3+
One pass audits every seed job with Agent B in learning mode (each gets a fresh,
4+
monotonic run index so patterns recur across distinct runs), then runs the janitor
5+
once to consolidate the diary into the playbook. A bot-blocked seed page degrades
6+
to a SERP-only audit (see fetch_page) rather than aborting the pass.
7+
8+
The agent / janitor / diary are injectable so the orchestration is testable without
9+
live LLM calls.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import asyncio
15+
import logging
16+
from collections.abc import Awaitable, Callable
17+
from typing import Protocol
18+
19+
from pydantic import BaseModel
20+
21+
from app.agents.agent_b import run_agent_b
22+
from app.jobs.seed_jobs import SEED_JOBS
23+
from app.memory.janitor import run_janitor
24+
from app.models import AgentRunResult
25+
from app.models.memory import PlaybookEntry
26+
from app.repositories.diary import DiaryRepository
27+
from app.tools.registry import build_default_registry
28+
29+
logger = logging.getLogger("seed")
30+
31+
AgentFn = Callable[..., Awaitable[AgentRunResult]]
32+
JanitorFn = Callable[[], Awaitable[list[PlaybookEntry]]]
33+
34+
35+
class RunCounter(Protocol):
36+
async def next_run(self) -> int: ...
37+
38+
39+
class SeedResult(BaseModel):
40+
jobs_run: int
41+
playbook_size: int
42+
43+
44+
async def run_seed_pass(
45+
*,
46+
jobs: list[tuple[str, str]] | None = None,
47+
passes: int = 1,
48+
replay: bool = False,
49+
agent: AgentFn | None = None,
50+
janitor: JanitorFn | None = None,
51+
diary: RunCounter | None = None,
52+
) -> SeedResult:
53+
"""Audit every seed job with learning on, then consolidate once."""
54+
jobs = jobs if jobs is not None else SEED_JOBS
55+
agent = agent or run_agent_b
56+
janitor = janitor or run_janitor
57+
diary = diary or DiaryRepository()
58+
registry = build_default_registry(replay=replay)
59+
60+
jobs_run = 0
61+
for _ in range(passes):
62+
for url, keyword in jobs:
63+
run_index = await diary.next_run()
64+
await agent(
65+
url,
66+
keyword,
67+
registry=registry,
68+
replay=replay,
69+
memory_on=True,
70+
learn=True,
71+
run_index=run_index,
72+
persist=True,
73+
)
74+
jobs_run += 1
75+
76+
promoted = await janitor()
77+
logger.info("seed pass done: %d jobs, %d rules promoted", jobs_run, len(promoted))
78+
return SeedResult(jobs_run=jobs_run, playbook_size=len(promoted))
79+
80+
81+
async def run_idle_loop(*, interval_seconds: int, passes: int = 1) -> None:
82+
"""Forever: run one seed pass, then sleep. Gated by config; off by default."""
83+
while True:
84+
try:
85+
await run_seed_pass(passes=passes)
86+
except Exception: # never let one bad pass kill the idle loop
87+
logger.exception("seed pass failed; will retry next tick")
88+
await asyncio.sleep(interval_seconds)

backend/tests/test_seed_runner.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Seed runner orchestration — verified without any live LLM call."""
2+
3+
import pytest
4+
5+
from app.jobs.seed_runner import run_seed_pass
6+
from app.models import AgentRunResult
7+
8+
9+
class _FakeDiary:
10+
"""Hands out monotonic run indices, like the real diary repo."""
11+
12+
def __init__(self) -> None:
13+
self._n = 0
14+
15+
async def next_run(self) -> int:
16+
n = self._n
17+
self._n += 1
18+
return n
19+
20+
21+
@pytest.mark.asyncio
22+
async def test_runs_every_job_with_learning_then_consolidates() -> None:
23+
calls: list[tuple[str, str, int, bool]] = []
24+
25+
async def fake_agent(url, keyword, **kwargs) -> AgentRunResult:
26+
calls.append((url, keyword, kwargs["run_index"], kwargs["learn"]))
27+
return AgentRunResult(agent="B", url=url, keyword=keyword)
28+
29+
janitor_calls = 0
30+
31+
async def fake_janitor():
32+
nonlocal janitor_calls
33+
janitor_calls += 1
34+
return []
35+
36+
jobs = [("https://a.test", "ka"), ("https://b.test", "kb")]
37+
result = await run_seed_pass(
38+
jobs=jobs,
39+
passes=2,
40+
agent=fake_agent,
41+
janitor=fake_janitor,
42+
diary=_FakeDiary(),
43+
)
44+
45+
assert result.jobs_run == 4 # 2 jobs x 2 passes
46+
assert janitor_calls == 1 # consolidate once, after the passes
47+
assert all(learn for *_, learn in calls) # learning on for every job
48+
assert [run_index for *_, run_index, _ in calls] == [0, 1, 2, 3] # monotonic

frontend/src/App.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useState, type FormEvent } from 'react'
22
import './App.css'
3-
import { fetchDiary, fetchPlaybook, runAgents } from './api'
3+
import { fetchDiary, fetchPlaybook, runAgents, runSeed } from './api'
44
import { AgentColumn } from './components/AgentColumn'
55
import { MemoryPanel } from './components/MemoryPanel'
66
import type { DiaryEntry, PlaybookEntry, RunResponse } from './types'
@@ -86,6 +86,13 @@ function MemoryPage() {
8686
const [playbook, setPlaybook] = useState<PlaybookEntry[] | null>(null)
8787
const [diary, setDiary] = useState<DiaryEntry[]>([])
8888
const [error, setError] = useState<string | null>(null)
89+
const [seeding, setSeeding] = useState(false)
90+
91+
async function load() {
92+
const [p, d] = await Promise.all([fetchPlaybook(), fetchDiary()])
93+
setPlaybook(p)
94+
setDiary(d)
95+
}
8996

9097
useEffect(() => {
9198
let active = true
@@ -102,10 +109,33 @@ function MemoryPage() {
102109
}
103110
}, [])
104111

112+
async function handleGrow() {
113+
if (!confirm('Run the curated seed jobs live? This fetches each page and calls the LLM — slow and spends tokens.')) return
114+
setSeeding(true)
115+
setError(null)
116+
try {
117+
await runSeed(1)
118+
await load()
119+
} catch (e) {
120+
setError(e instanceof Error ? e.message : 'seeding failed')
121+
} finally {
122+
setSeeding(false)
123+
}
124+
}
125+
105126
if (error) return <p className="hint error">{error}</p>
106127
if (!playbook) return <p className="hint">loading memory…</p>
107128
return (
108129
<main className="learning">
130+
<div>
131+
<button onClick={handleGrow} disabled={seeding}>
132+
{seeding ? 'Growing B’s memory…' : 'Grow B’s memory (run seed jobs)'}
133+
</button>
134+
<p className="hint">
135+
Cycles a curated list of real pages with learning on, then consolidates. Live and slow;
136+
run a few times so patterns recur and get promoted.
137+
</p>
138+
</div>
109139
<MemoryPanel playbook={playbook} diary={diary} />
110140
</main>
111141
)

frontend/src/api.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// call raw fetch. Paths are relative to VITE_API_BASE (default `/api`), proxied
33
// to the backend by Vite.
44

5-
import type { DiaryEntry, HealthResponse, PlaybookEntry, RunResponse } from './types'
5+
import type { DiaryEntry, HealthResponse, PlaybookEntry, RunResponse, SeedResult } from './types'
66

77
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
88

@@ -44,3 +44,23 @@ export async function runAgents(url: string, keyword: string): Promise<RunRespon
4444
}
4545
return (await response.json()) as RunResponse
4646
}
47+
48+
// Work the curated seed jobs with learning on, then consolidate. Live + slow.
49+
export async function runSeed(passes = 1): Promise<SeedResult> {
50+
const response = await fetch(`${API_BASE}/run/seed`, {
51+
method: 'POST',
52+
headers: { 'Content-Type': 'application/json' },
53+
body: JSON.stringify({ passes }),
54+
})
55+
if (!response.ok) {
56+
let message = `Seeding failed (HTTP ${response.status}).`
57+
try {
58+
const body = (await response.json()) as { detail?: string }
59+
if (body.detail) message = body.detail
60+
} catch {
61+
/* non-JSON body — keep the generic message */
62+
}
63+
throw new Error(message)
64+
}
65+
return (await response.json()) as SeedResult
66+
}

frontend/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export interface RunResponse {
3838
note?: string | null // set when the page body couldn't be fetched (SERP-only run)
3939
}
4040

41+
export interface SeedResult {
42+
jobs_run: number
43+
playbook_size: number
44+
}
45+
4146
export interface PlaybookEntry {
4247
page_kind: string
4348
rec_type: string

0 commit comments

Comments
 (0)