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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ rename / release-pipeline wiring.
| Subagents | `graph/subagents/config.py` | DeerFlow-pattern delegation via a `task()` tool; one placeholder `worker` ships |
| Starter tools | `tools/lg_tools.py` | Keyless general tools (`current_time`, `calculator` safe AST eval, `web_search` via DuckDuckGo, `fetch_url`) plus memory tools (`memory_ingest`, `memory_recall`, `memory_list`, `memory_stats`, `daily_log`) bound to the bundled store |
| Knowledge store | `knowledge/store.py` | sqlite + FTS5 (LIKE fallback). One `chunks` table for operator notes, daily-log entries, and conversation findings. Default-on; turn off with `middleware.knowledge: false` |
| Scheduler | `scheduler/` | `schedule_task` / `list_schedules` / `cancel_schedule` tools backed by either a bundled sqlite scheduler or a Workstacean adapter (env-selected). Multi-agent-safe — every job is namespaced by `AGENT_NAME`. See [Schedule future work](./docs/guides/scheduler.md) |
| Eval harness | `evals/` | Side-effect-verified A2A test harness — audit log + reply text + KB state. `python -m evals.runner` against a running agent. See [Eval your fork](./docs/guides/evals.md) |
| Tracing | `tracing.py` | Langfuse trace_session with distributed `a2a.trace` propagation and the OTel cross-context-detach filter |
| Observability | `metrics.py`, `audit.py` | Prometheus metrics with per-agent prefix, JSONL audit log with trace IDs |
Expand Down
22 changes: 22 additions & 0 deletions TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,28 @@ See [Eval your fork](./docs/guides/evals.md) for what each case
asserts, how the three assertion channels work, and how to add
cases for your fork's new tools.

## 9b. Scheduler — local sqlite or Workstacean

The bundled scheduler ships three agent tools — `schedule_task`,
`list_schedules`, `cancel_schedule` — backed by either a local
sqlite poller or a Workstacean adapter, selected at startup via env:

```bash
# Default: local sqlite, persists at /sandbox/scheduler/<agent_name>/jobs.db
python server.py

# Workstacean: set both and restart
export WORKSTACEAN_API_BASE=http://your-workstacean:3000
export WORKSTACEAN_API_KEY=...
python server.py
```

Multi-fork safety: every job is namespaced by `AGENT_NAME`, so
spinning up `gina-personal` next to `gina-work` (or any number of
ginas under one Workstacean) doesn't cross-fire prompts. See
[Schedule future work](./docs/guides/scheduler.md) for the full
firing model and integration notes.

## 9a. Understand the skill loop

protoAgent's skill loop lets your agent learn from experience automatically.
Expand Down
14 changes: 10 additions & 4 deletions config/langgraph-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,22 @@ subagents:
- memory_list
- memory_stats
- daily_log
- schedule_task
- list_schedules
- cancel_schedule
max_turns: 20

middleware:
# All three middlewares default ON. The knowledge middleware needs a
# store; the template constructs one automatically (see
# ``server.py::_build_knowledge_store``). Set ``knowledge: false`` if
# your fork is purely stateless.
# All four subsystems default ON. The template constructs the
# knowledge store + scheduler backends automatically (see
# ``server.py::_build_knowledge_store`` and ``_build_scheduler``).
# Flip any of these to ``false`` to opt out — the corresponding
# tools (memory_*, schedule_*) are dropped from the agent loop
# without touching the worker subagent's tool allowlist.
knowledge: true
audit: true
memory: true
scheduler: true

knowledge:
db_path: /sandbox/knowledge/agent.db
Expand Down
1 change: 1 addition & 0 deletions docs/guides/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ Task-oriented procedures. Assumes you already have a running agent (see [Tutoria
| [Configure subagents](/guides/subagents) | You want specialized delegates beyond the placeholder `worker` |
| [Wire Langfuse + Prometheus](/guides/observability) | You need traces and metrics in production |
| [Eval your fork](/guides/evals) | You want a baseline pass-rate for the tools / memory / A2A surface in your fork |
| [Schedule future work](/guides/scheduler) | You want the agent to defer tasks to itself ("remind me tomorrow", recurring sweeps) — local sqlite or Workstacean-backed |
| [Deploy via GHCR](/guides/deploy) | You're ready to ship and want auto-deploy wired up |
178 changes: 178 additions & 0 deletions docs/guides/scheduler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Schedule future work

protoAgent ships a scheduler so the agent can defer tasks to itself —
"remind me about X tomorrow", "every Monday morning summarize last
week's logs", "at 3pm check the deploy". Two backends ship by default;
the agent-facing tool surface is identical regardless of which one is
active.

## When to read this

- You want forks (or your own multiple agents) to support reminders,
recurring sweeps, or any "do this later" intent.
- You're running protoWorkstacean and want scheduled fires to flow
through the existing bus.
- You're spinning up multiple protoAgent instances on one box and
need scheduling state to stay isolated per agent.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## The three tools

When the scheduler is active, three tools land in `get_all_tools()`:

| Tool | What it does |
|---|---|
| `schedule_task(prompt, when, job_id?)` | Persist a future invocation. `when` is cron (`"0 9 * * *"`) or ISO-8601 (`"2026-05-01T15:00:00"`). |
| `list_schedules()` | Show all jobs visible to *this* agent. |
| `cancel_schedule(job_id)` | Remove a job by id. |

Prompts are self-contained — the agent has no memory of the
scheduling moment when the task fires, so write the prompt as a fresh
turn ("review last week's pipeline incidents and post a summary",
not "do that thing we discussed").

## Backend selection

`server.py::_build_scheduler` picks at startup:

1. `middleware.scheduler: false` in YAML → no scheduler. The three
tools don't ship. (Symmetric with `middleware.knowledge` /
`middleware.memory` — drawer/wizard editable.)
2. `SCHEDULER_DISABLED=1` env → no scheduler. Runtime escape hatch
for fleet operators who can't edit config.
3. `WORKSTACEAN_API_BASE` + `WORKSTACEAN_API_KEY` set →
**`WorkstaceanScheduler`**.
4. Otherwise → **`LocalScheduler`** (sqlite, asyncio polling).

Both backends honor the same `SchedulerBackend` protocol; the agent
loop never knows which one is wired up. The scheduler is **default
on** — explicitly opt out via either config path above when a fork
wants a stateless agent with no scheduling surface.

```bash
# Solo / local dev — falls through to LocalScheduler automatically.
python server.py

# Workstacean install — set both env vars and restart.
export WORKSTACEAN_API_BASE=http://your-workstacean-host:3000
export WORKSTACEAN_API_KEY=<key>
python server.py
```

> **protoLabs operators**: the fleet's Workstacean lives on the
> `ava` node; `WORKSTACEAN_API_KEY` is in the org's secrets manager
> under `secret-management → workstacean`. Coordinate with the team
> for the exact URL.

## Multi-agent isolation

Every job is namespaced by `AGENT_NAME` so spinning up
`gina-personal` alongside `gina-work` on the same box doesn't
cross-fire prompts.

| Backend | How it isolates |
|---|---|
| Local | DB path per agent: `/sandbox/scheduler/<agent_name>/jobs.db` (falls back to `~/.protoagent/scheduler/<agent_name>/jobs.db`). Every row also carries `agent_name`; reads filter on it. |
| Workstacean | Job IDs are prefixed `<agent_name>-...`; topics are namespaced `cron.<agent_name>.<job_id>`. One Workstacean install can serve N forks safely. |

If you supply your own `job_id` in `schedule_task`:

- Local: the id is stored as-is. Two agents sharing one DB path with
the same user-supplied id will trip a primary-key collision (the
second add raises a clear error). To avoid it, let the scheduler
auto-generate (the auto-id is `<agent>-<uuid>`).
- Workstacean: the adapter prepends `<agent>-` if your id doesn't
already start with it, so cross-agent collisions are impossible.

## Local backend — how firing works

The local scheduler runs an asyncio polling task on FastAPI's
`startup` event. Once a second:

1. Read jobs where `next_fire <= now()` and `enabled = 1`.
2. For each due job: POST to `http://127.0.0.1:<active_port>/a2a` as
a `message/send` with the job's prompt as the message text. Bearer
+ X-API-Key are forwarded automatically.
3. One-shot ISO jobs are deleted after firing. Cron jobs reschedule
forward via `croniter`.

Going through HTTP rather than calling into the graph directly buys
parity with real callers — the audit log, cost-v1 capture, and
push-notification path all behave identically.

### Missed-fire recovery

On startup, jobs whose `next_fire` is in the past are inspected:

- **Within the last 24h** — fire on the next tick (so a 5-minute
outage doesn't lose an upcoming reminder).
- **Older than 24h** — cron jobs roll forward to the next slot
without firing; one-shot jobs are dropped. This matches
Workstacean's recovery behaviour and avoids flooding the agent
with stale prompts after a long downtime.

### Persistence path

```bash
# Default (Docker)
/sandbox/scheduler/<agent_name>/jobs.db

# Local fallback (when /sandbox isn't writable)
~/.protoagent/scheduler/<agent_name>/jobs.db

# Override
export SCHEDULER_DB_DIR=/var/data/agents
# → /var/data/agents/<agent_name>/jobs.db
```

Mount a volume at the configured path to survive container
restarts (analogous to `audit/` and `knowledge/`).

## Workstacean backend — how firing works

When `WORKSTACEAN_API_BASE` and `WORKSTACEAN_API_KEY` are set, the
adapter publishes to `POST {base}/publish` with topic
`command.schedule` and the action wrapper Workstacean expects. See
the [Workstacean scheduler reference](https://protolabsai.github.io/protoWorkstacean/reference/scheduler/)
for the payload shape.

When the schedule fires, Workstacean publishes the inner payload to
`cron.<agent_name>.<job_id>`. **Workstacean does not natively dispatch
to A2A endpoints today** — your fork needs to wire a bridge that
subscribes to `cron.<agent_name>.*` and POSTs to the protoAgent's
`/a2a` endpoint.

### Topic prefix override

If your existing Workstacean bus uses a different convention:

```bash
export WORKSTACEAN_TOPIC_PREFIX="myorg.cron.gina"
# → topics fire on myorg.cron.gina.<job_id>
```

### `list_schedules()` returns empty under Workstacean

Workstacean's `list` action publishes its response on the
`schedule.list` topic — there's no synchronous reply on `/publish`.
The adapter intentionally doesn't subscribe. If you need live
introspection, query Workstacean directly or run the local backend.

## Adding a case to your eval suite

The default `evals/tasks.json` doesn't include scheduler cases (the
fire path is async — a single eval run can't easily test that the
scheduled prompt arrives). For forks that want it, the pattern is:

1. `schedule_task(prompt, "<near-future ISO>")` in setup.
2. Wait > 1 second.
3. Assert on the audit log and/or KB state for the *fired* prompt's
side effects.

Document the case as `category: "scheduler"` and gate at >= 2/3
attempts to absorb timing jitter.

## References

- [Workstacean scheduler reference](https://protolabsai.github.io/protoWorkstacean/reference/scheduler/)
- [Configuration](/reference/configuration#scheduler) — env vars
- [Eval your fork](/guides/evals) — for the testing pattern above
15 changes: 15 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ middleware:
knowledge: true
audit: true
memory: true
scheduler: true

knowledge:
db_path: /sandbox/knowledge/agent.db
Expand Down Expand Up @@ -71,6 +72,7 @@ Adding a new subagent name to the YAML requires matching entries in `graph/subag
| `knowledge` | `true` | Inject retrieved knowledge into state before LLM calls. Backed by the bundled `KnowledgeStore` (sqlite + FTS5). Set `false` for a stateless agent. |
| `audit` | `true` | Append every tool call to `/sandbox/audit/audit.jsonl`. |
| `memory` | `true` | Persist a session summary on terminal turn and asynchronously index conversation findings under `domain='finding'`. |
| `scheduler` | `true` | Wire the bundled scheduler backend (local sqlite, or `WorkstaceanScheduler` when env vars are set). Drops the `schedule_task` / `list_schedules` / `cancel_schedule` tools from the agent loop when `false`. Has the same effect as `SCHEDULER_DISABLED=1` — but `middleware.scheduler: false` is the canonical opt-out (drawer/wizard editable, survives restarts), while the env var is a runtime escape hatch for fleet operators who can't edit YAML in the moment. |

## `knowledge`

Expand All @@ -83,3 +85,16 @@ Only read when `middleware.knowledge` is `true`.
| `top_k` | `5` | Results per query fed into state. |

The bundled store is sqlite + FTS5 (with an automatic LIKE fallback when FTS5 isn't available). One `chunks` table; the `domain` column distinguishes operator-set notes (`memory_ingest`), daily-log entries (`daily_log`), and conversation findings extracted by `MemoryMiddleware` (`domain='finding'`).

## Scheduler

Scheduler **enable/disable** is YAML-controlled (`middleware.scheduler` above) so the drawer can flip it without a restart. Backend **selection and runtime knobs** (which backend, where to write the sqlite, where to publish, etc.) are env-driven so the same container image can run under either backend without a rebuild. See [Schedule future work](/guides/scheduler) for the full guide.

| Env var | Default | What |
|---|---|---|
| `WORKSTACEAN_API_BASE` | unset | When set together with `WORKSTACEAN_API_KEY`, swaps the bundled local scheduler for the `WorkstaceanScheduler` HTTP adapter. |
| `WORKSTACEAN_API_KEY` | unset | Auth token sent as `X-API-Key` to Workstacean's `/publish`. |
| `WORKSTACEAN_TOPIC_PREFIX` | `cron.<agent_name>` | Override the bus topic the adapter fires on, when your Workstacean install uses a different convention. |
| `SCHEDULER_DB_DIR` | `/sandbox/scheduler` | Local backend: parent directory for `<agent_name>/jobs.db`. Falls back to `~/.protoagent/scheduler/<agent_name>/jobs.db` when unwritable. |
| `SCHEDULER_INVOKE_URL` | `http://127.0.0.1:<active_port>` | Local backend: where to POST `message/send` when a job fires. Override only if the agent's A2A endpoint isn't on localhost. |
| `SCHEDULER_DISABLED` | unset | Runtime escape hatch — set to `1` / `true` to drop the scheduler tools entirely without editing YAML. `middleware.scheduler: false` is the canonical opt-out. |
7 changes: 4 additions & 3 deletions graph/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ async def task(
def create_agent_graph(
config: LangGraphConfig,
knowledge_store=None,
scheduler=None,
include_subagents: bool = True,
):
"""Create the protoAgent LangGraph agent.
Expand All @@ -167,7 +168,7 @@ def create_agent_graph(
"""
llm = create_llm(config)

all_tools = get_all_tools(knowledge_store)
all_tools = get_all_tools(knowledge_store, scheduler=scheduler)

if include_subagents:
task_tool = _build_task_tool(config, all_tools)
Expand All @@ -189,12 +190,12 @@ def create_agent_graph(
return agent


def create_simple_agent(config: LangGraphConfig, knowledge_store=None):
def create_simple_agent(config: LangGraphConfig, knowledge_store=None, scheduler=None):
"""Create a simple agent without subagents (for debugging/testing)."""
from langgraph.prebuilt import create_react_agent

llm = create_llm(config)
all_tools = get_all_tools(knowledge_store)
all_tools = get_all_tools(knowledge_store, scheduler=scheduler)

system_prompt = build_system_prompt(include_subagents=False)

Expand Down
8 changes: 7 additions & 1 deletion graph/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,19 @@ class LangGraphConfig:
"current_time", "calculator", "web_search", "fetch_url",
"memory_ingest", "memory_recall", "memory_list", "memory_stats",
"daily_log",
"schedule_task", "list_schedules", "cancel_schedule",
],
max_turns=20,
))

# Middleware toggles
# Middleware / subsystem toggles. All default-on so a fresh fork has
# a working memory loop + scheduler on day one. Forks that want a
# purely stateless agent (no KB, no scheduled tasks) can flip these
# via the drawer or by editing the YAML directly.
knowledge_middleware: bool = True
audit_middleware: bool = True
memory_middleware: bool = True
scheduler_enabled: bool = True

# Knowledge store — sqlite + FTS5, see ``knowledge/store.py``.
# The default path lives under ``/sandbox/`` to play well with the
Expand Down Expand Up @@ -108,6 +113,7 @@ def from_yaml(cls, path: str | Path) -> "LangGraphConfig":
knowledge_middleware=middleware.get("knowledge", cls.knowledge_middleware),
audit_middleware=middleware.get("audit", cls.audit_middleware),
memory_middleware=middleware.get("memory", cls.memory_middleware),
scheduler_enabled=middleware.get("scheduler", cls.scheduler_enabled),
knowledge_db_path=knowledge.get("db_path", cls.knowledge_db_path),
embed_model=knowledge.get("embed_model", cls.embed_model),
knowledge_top_k=knowledge.get("top_k", cls.knowledge_top_k),
Expand Down
27 changes: 24 additions & 3 deletions graph/config_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ def config_to_dict(config: LangGraphConfig) -> dict[str, Any]:
"knowledge": config.knowledge_middleware,
"audit": config.audit_middleware,
"memory": config.memory_middleware,
"scheduler": config.scheduler_enabled,
},
"knowledge": {
"db_path": config.knowledge_db_path,
Expand Down Expand Up @@ -319,10 +320,30 @@ def list_gateway_models(


def list_available_tools(knowledge_store: Any = None) -> list[str]:
"""Return every tool name the runtime would wire into the graph."""
from tools.lg_tools import get_all_tools
"""Return every tool name the runtime *could* wire into the graph.

The wizard's tool checkbox group reads this. We deliberately
expose the scheduler tool names even when no scheduler has been
constructed yet (fresh boot, pre-setup) — otherwise the wizard
would hide tools that the runtime will register the moment the
user finishes setup. Same logic for memory tools when the
knowledge store is absent.
"""
from tools.lg_tools import (
MEMORY_TOOL_NAMES,
SCHEDULER_TOOL_NAMES,
get_all_tools,
)

return [t.name for t in get_all_tools(knowledge_store)]
names = [t.name for t in get_all_tools(knowledge_store)]
# Deduplicate while preserving order: tools already present
# (because their backend was passed in) shouldn't appear twice.
seen = set(names)
for extra in (*MEMORY_TOOL_NAMES, *SCHEDULER_TOOL_NAMES):
if extra not in seen:
names.append(extra)
seen.add(extra)
return names


# ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions graph/subagents/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class SubagentConfig:
"current_time", "calculator", "web_search", "fetch_url",
"memory_ingest", "memory_recall", "memory_list", "memory_stats",
"daily_log",
"schedule_task", "list_schedules", "cancel_schedule",
],
max_turns=20,
)
Expand Down
Loading
Loading