pyrtan / sourcev0.19.0 · main

Merge remote-tracking branch 'origin/main' into claude/great-heisenberg-o6ocah

Claude · 2026-10-28 · 0577d948b6fd7e2906021cab8038451a80b6f855 · parents f19cc59 b86a805

Changes

 CONTRIBUTING.md                   |  13 +-
 README.md                         |  77 +++++++-
 _meta/agents.md                   | 168 ++++++++++++++++
 _meta/design.md                   |  14 +-
 deploy/README.md                  |  49 +++++
 pyproject.toml                    |   2 +-
 src/pyrtan/__init__.py            |   2 +-
 src/pyrtan/cli.py                 | 154 ++++++++++++++-
 src/pyrtan/grants.py              |   4 +-
 src/pyrtan/hub.py                 |  13 +-
 src/pyrtan/js/agents.js           | 627 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/pyrtan/js/pyrtan.js           | 154 ++++++++++++++-
 src/pyrtan/page.py                | 254 +++++++++++++++++++++++-
 src/pyrtan/service.py             |  16 +-
 src/pyrtan/suggestions.py         | 348 ++++++++++++++++++++++++++++++++
 tests/agents.cjs                  | 172 ++++++++++++++++
 tests/browser/console_agents.cjs  |  76 +++++++
 tests/conformance/make_vectors.py |  43 +++-
 tests/conformance/run.cjs         |   4 +
 tests/conformance/vectors.json    | 368 ++++++++++++++++++++++++++++++++++
 tests/test_agents.py              | 330 +++++++++++++++++++++++++++++++
 tests/test_conformance.py         |   2 +-
 uv.lock                           |   2 +-
 23 files changed, 2849 insertions(+), 43 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 31bacb7..a0b9970 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -67,9 +67,16 @@ tree author who has to learn it. A new kind needs a case that `observe`,
`decide`, `set`, `each` and `delegate` cannot express together, written down in the pull request. The same goes for a new filter or condition.
-Nothing in `src/pyrtan` may call a model, execute code from a tree, or reach
-a host outside a tree's `hosts`. Those are the three rules the design rests
-on; see `_meta/design.md`. A fourth, from `_meta/platform.md`: nothing in
+No run may call a model, nothing may execute code from a tree, and nothing
+may reach a host outside a tree's `hosts`. Those are the three rules the
+design rests on; see `_meta/design.md`. The one place a model appears is
+an agent in the console (`src/pyrtan/js/agents.js`), which a person starts,
+which reaches the swarm only through its public endpoints, and whose
+suggestions are data that rules check before anyone sees them and again
+before `pyrtan grow` writes them; nothing in the Python package calls a
+model. A change that lets an agent's suggestion add I/O, a host or a read
+of `env`, or apply itself without a person, breaks the first rule; see
+`_meta/agents.md`. A fourth, from `_meta/platform.md`: nothing in
`src/pyrtan` knows about tenants, plans or billing. The hosted product runs this code unchanged. diff --git a/README.md b/README.md index 316cd97..6c800f8 100644 --- a/README.md +++ b/README.md
@@ -26,7 +26,10 @@ live: a service reloads a tree the moment its file changes, a tree can hand
part of its work to any other tree in the swarm, every decision that meets a value no branch covers is recorded as a **gap**, and every sentence nobody answers is recorded as **unanswered** with a proposed tree. That is how the
-swarm tells its authors where to grow next.
+swarm tells its authors where to grow next. An **agent**, an open-weights
+model you start in the console, goes looking for both and suggests what
+to write; it sits beside the trees, never inside a run, and a person
+accepts what it suggests.
``` $ pyrtan run "what's the weather in Oslo" --trees examples
@@ -161,7 +164,7 @@ hosted box is not lent out. The design is in [`_meta/io.md`](_meta/io.md).
| `pyrtan run "…" --trees DIR` | Answers a sentence in this process, printing the trace. `--replay FIXTURE` answers HTTP from a recording; `--record FIXTURE` makes one. | | `pyrtan run --tree NAME --input k=v` | Runs a named tree. | | `pyrtan match "…" --trees DIR` | How a sentence reads, without running anything: the rewrites, every trigger's verdict, the winner. The tool for writing triggers. |
-| `pyrtan serve DIR --port 8701` | Hosts the trees as a service: `/health`, `/capabilities`, `/intent`, `/run`, `/resume`, `/gaps`, `/unanswered`. Reloads trees when their files change. `--gaps FILE` keeps gaps across restarts; `--egress public` refuses private addresses. |
+| `pyrtan serve DIR --port 8701` | Hosts the trees as a service: `/health`, `/capabilities`, `/intent`, `/run`, `/resume`, `/gaps`, `/unanswered`, `/suggestions`. Reloads trees when their files change. `--gaps FILE` keeps gaps across restarts; `--egress public` refuses private addresses. |
| `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. `--trees DIR` holds trees in the hub itself; `--federate` lets other hubs follow it and it follow them; `--listed` puts it in the directory; `--lan` finds hubs on the local network by multicast, with nobody to ask; `--chat` keeps a lobby where people meet strangers and bots (`--no-chat-typing` turns its typing indicator off, `--chat-public` lets callers without the token in, to meet people only; `--chat-reach N` links an open lobby with up to N others, for chat only). | | `pyrtan keep KEY [FILE] --at HUB` | Gives your hub a blob to keep; its keepers get copies. `kept` shows who holds what; `fetch KEY` gets a blob back from your hub or whoever holds it. | | `pyrtan follow [URL] --at HUB` | Makes your hub follow another: it then routes sentences to that hub's trees too. With no URL, `pyrtan join`. `--network NAME` follows within a network of yours; `--listed` also puts your hub in the directory. `unfollow` undoes it; `peers` lists services, hubs followed and followers; `directory` lists every hub that asked to be found, as the swarm knows it. |
@@ -171,6 +174,7 @@ hosted box is not lent out. The design is in [`_meta/io.md`](_meta/io.md).
| `pyrtan chat --at HUB` | Talks to a random stranger or a bot through a hub started `--chat`. `--bot` for a bot, `--people` for people only, `--interests` to meet someone who shares one, `--trace` to see the path behind each bot line, `--no-typing` to hide "Stranger is typing…". | | `pyrtan gaps --at URL` | Every decision that met a value with no branch: the branches to write next. | | `pyrtan unanswered --at URL` | Every sentence no tree answered, with the tree that would: the trees to write next. `--scaffold DIR` writes them. |
+| `pyrtan suggestions --at URL` | What agents beside the swarm suggested: a branch's meaning, a trigger, a tree. `pyrtan grow --suggestions URL_OR_FILE --trees DIR --apply` writes them, through the rules. |
| `pyrtan new --from "…"` | Scaffolds a tree file from a sentence: numbers and hosts in it become slots, and the file passes `pyrtan check`. `pyrtan new NAME` scaffolds from a name. | | `pyrtan search WORDS` | Finds trees in a catalog (default: the one at pyrtan.com). | | `pyrtan pull NAME[@VERSION]` | Fetches a tree from a catalog into your trees folder, verified against the catalog's index. |
@@ -596,6 +600,54 @@ $ pyrtan grow --at http://127.0.0.1:8700 --trees trees --apply
Unanswered sentences survive a restart too, with `--unanswered FILE` on a service or hub.
+## Agents: models beside the swarm
+
+The console's **Agents** tab starts an agent: an open-weights model
+(Qwen2.5, Qwen3, Phi-3.5 or SmolLM2, under Apache-2.0 or MIT) running
+in the browser on WebGPU through WebLLM, or on a server of your own that
+speaks the OpenAI chat API (Ollama, llama.cpp). Nothing downloads until
+you press Start. The agent is a client of the swarm like you are:
+
+- **It tests the trees.** *Patrol* (or `/probe weather`) has it write
+ sentences a person might say, read them here by the same rules the
+ swarm reads them, and run the ones that reach the tree. A value no
+ branch covers is a real gap, with a real trace. Sentences that read to
+ nothing, or to the wrong tree, are what it reports next.
+- **It suggests.** What a gap's value means (a `done` or a `fail`, and
+ its words), a pattern that would let a tree read the sentences it
+ missed, a tree for a sentence nobody answers. Each is checked by rules
+ before you see it: the branch's condition is the one `pyrtan grow`'s
+ rules draft, not the model's; a pattern is kept only if the reading
+ says it reads the sentences and takes none from another tree; no
+ suggestion adds a host, any I/O, or a read of `env`.
+- **You accept.** In a browser that is its own hub, the tree grows in
+ the page at once. On a hub, the suggestion is kept at `/suggestions`;
+ `pyrtan grow --suggestions HUB --trees DIR --apply` checks it again
+ and writes it (or download the accepted ones and point `--suggestions`
+ at the file). Dismiss one and say why, and the agent remembers.
+- **You can talk to it.** It answers in its own words and, when a tree
+ can answer, asks the swarm and quotes the tree, whose name and path are
+ shown beside what it said. `/help` lists the commands (`/ask`, `/read`,
+ `/probe`, `/gaps`, `/unanswered`, `/patrol`, `/remember`, `/memory`).
+- **It remembers and has skills.** Its memory (your notes, its own,
+ what it probed, what was accepted, dismissed or refused, the
+ conversation) is kept in the browser per hub, and the relevant part
+ goes into every prompt. Its skills are instructions for each job, the
+ way a Claude skill is: five built in, and your own (a name, when it
+ applies, and the text; Markdown with front matter, or JSON), called as
+ `/name` in chat.
+
+```
+$ pyrtan suggestions --at http://127.0.0.1:8700
+branch weather.sky: code = 100 means fail: 'Code 100 is not a weather code we know.' x1
+ by scout (Qwen2.5-1.5B-Instruct-q4f16_1-MLC): WMO codes stop at 99
+trigger weather: 'is it raining in {place}', covering 'is it raining in Oslo' x2
+ by scout (Qwen2.5-1.5B-Instruct-q4f16_1-MLC): people ask about rain
+$ pyrtan grow --suggestions http://127.0.0.1:8700 --trees trees --apply
+```
+
+The design is in [`_meta/agents.md`](_meta/agents.md).
+
## Sentences Pyrtan reads a sentence with rules, not a model, and the trace's first
@@ -635,10 +687,12 @@ slot refused. The whole set of rules is in
## What Pyrtan refuses
-- **No model in the loop.** Nothing in this repository calls an LLM. The
- reading of a sentence is tables and rules, each named in the trace when
- it fires. A model may one day sit *beside* the swarm, proposing branches
- for recorded gaps; it will not sit inside a run deciding anything.
+- **No model in the loop.** No run calls an LLM, and nothing in the
+ Python package does. The reading of a sentence is tables and rules,
+ each named in the trace when it fires. A model sits only *beside* the
+ swarm, as an agent a person starts in the console: it asks like any
+ caller and proposes branches, triggers and trees, which rules check
+ and a person accepts. It never sits inside a run deciding anything.
- **No endpoint a tree did not name.** Every HTTP node's host is checked against the tree's `hosts` at load time where it is literal and at run time where it is templated. Every further form of I/O arrives with an
@@ -654,7 +708,8 @@ slot refused. The whole set of rules is in
src/pyrtan/ the package: tree, nlp, intent, template, conditions, extract, html, transport, runtime, trace, registry, gaps, grow, service, hub, federation, identity, names, grants, page (the console),
- catalog, cli
+ suggestions, catalog, cli; js/ holds pyrtan.js (the runtime
+ again), identity.js and agents.js (agents beside the swarm)
examples/ trees that run against real public APIs, and offline against tests/fixtures/examples.json tests/ pytest; a swarm of two services behind a hub runs on localhost
@@ -669,7 +724,7 @@ site/ pyrtan.com: the landing page, the hub list, the catalog
## Status
-0.17.0. The core, the service, the hub, the command, the console, the
+0.18.0. The core, the service, the hub, the command, the console, the
catalog (namespaces, publishing by pull request through the gate, gap reports gossiped back to authors), the language rules, federation between hubs, networks (any hub starts one; the default one is built
@@ -684,7 +739,11 @@ hubs it follows, bots made of trees, a typing indicator, lobbies open to the pub
token, keep its gaps and unanswered sentences, refuse private addresses and its own box, and follow other swarms. Identities (keys, device notes, name claims, names at pyrtan.com and pins, grants on hubs, the console's
-key, `pyrtan id` and `pyrtan allow`) are built. Not yet built: hosting.
+key, `pyrtan id` and `pyrtan allow`) are built. Agents beside the swarm
+(open-weights models in the browser or on a server of your own that
+probe the trees, suggest branches, triggers and trees, chat, and keep a
+memory and skills; `/suggestions` and `pyrtan grow --suggestions`) are
+built. Not yet built: hosting.
See [`_meta/design.md`](_meta/design.md) for the runtime's design and [`_meta/platform.md`](_meta/platform.md) for pyrtan.com. diff --git a/_meta/agents.md b/_meta/agents.md new file mode 100644 index 0000000..f24838b --- /dev/null +++ b/_meta/agents.md
@@ -0,0 +1,168 @@
+# Design — Agents: open-weights models beside the swarm, so the trees grow
+
+| | |
+|---|---|
+| **Status** | **Built, v0.18.** Agents in the console (`agents.js`): WebLLM in the browser or a server of the person's own, probing, meanings for gaps, triggers for missed sentences, trees for unanswered ones, chat, memory and skills. `/suggestions` on services and hubs, `pyrtan suggestions`, `pyrtan grow --suggestions`. Tested with a scripted model in node and in Chromium. |
+| **Date** | 2026-10-28 |
+| **Decision** | A model may sit **beside** the swarm, never inside it. A person starts it in the console; it is a client of the public API like any caller; everything it proposes is data, checked by rules where it is made and again where it is written, and only a person accepts it. The trees stay deterministic; the model helps them grow. |
+| **Where** | `src/pyrtan/js/agents.js` (the agent), `src/pyrtan/js/pyrtan.js` (`grow`: the rule drafts and the checks), `src/pyrtan/suggestions.py` (the checks, the store, writing a suggestion into a file), `src/pyrtan/page.py` (the Agents tab), `tests/agents.cjs`, `tests/test_agents.py`, `tests/browser/console_agents.cjs`. |
+
+## 0. The brief, and the reading this design commits to
+
+*"Open source LLM agents that can run in the browser to test for gaps and
+make reports/suggestions to the deterministic tree, allowing our trees to
+grow organically. Also allow the user to chat with these agents, and for
+them to have memory/skills."*
+
+[`design.md`](design.md) §1 already said where a model could go: "A
+model may later sit *beside* the swarm, reading gaps and proposing
+branches for a person to accept; that is an author's tool, not a
+runtime." This is that tool. Every phrase was read against it:
+
+- **"Open source, in the browser."** Open weights under open licences
+ (Qwen2.5 and Qwen3 under Apache-2.0, Phi-3.5 under MIT, SmolLM2 under
+ Apache-2.0), run by WebLLM on the browser's GPU. A browser without
+ WebGPU can use a server of the person's own that speaks the OpenAI chat
+ API (Ollama, llama.cpp, vLLM). There is no hosted model, and no key of
+ pyrtan.com's anywhere.
+- **"Test for gaps."** The agent writes sentences a person might say
+ and runs the ones that reach a tree, through `/intent`, like any
+ caller. A value no branch covers becomes a gap the way any gap does:
+ recorded by the runtime, from a real run, with its trace. The model
+ finds where to look. The runtime says what it found.
+- **"Reports and suggestions."** Three kinds, and only three (§2). Each
+ is checked by rules in the page before the person sees it, kept at the
+ hub's `/suggestions` when the person accepts it, and checked again by
+ `pyrtan grow --suggestions` before it is written into a file.
+- **"Grow organically."** The loop is closed. In a browser that is its
+ own hub, an accepted suggestion grows the tree in the page at once. On
+ a hub, `pyrtan grow --suggestions HUB --trees DIR --apply` writes it,
+ the service reloads the file, and the next run takes the new branch.
+- **"Chat, memory, skills."** The person talks to the agent; the agent
+ asks the swarm when a tree can answer and quotes it. It remembers what
+ happened, what it was told, and what was accepted, dismissed or
+ refused. It follows skills: instructions for each of its jobs, built
+ in or written by the person, in the shape of a Claude skill.
+
+## 1. What this design refuses
+
+- **A model inside a run.** Nothing in the Python package and nothing in
+ `pyrtan.js`'s runtime calls one. An agent reaches the swarm only
+ through the endpoints a person's console uses; a tree's answer is the
+ tree's, with its trace, and the console says which is which (the
+ tree's name beside its words, the agent's beside its own).
+- **A suggestion that adds reach.** No suggestion adds an `observe`, an
+ `act`, a `delegate`, a host, a path, a command or a device. A branch's
+ new node is a `done` or a `fail`. A suggested tree is the rules'
+ scaffold, whose host is `example.com`; the hosts the agent guessed are
+ a comment. Invariant 3 of `design.md` holds whatever the model says.
+- **A suggestion that reads `env`.** What a model writes into a `say`
+ may read `inputs`, `state` and `tree`, never `env`, so its words cannot
+ be a way to carry a secret out.
+- **Trusting the model.** Every check is a rule: the shape
+ (`suggestions.problem`, `Pyrtan.grow.problem`, the same messages in
+ both), the condition and node id (`grow.draft_branch`'s rules, not the
+ model's), a pattern's reading (the same reader the swarm uses), a tree
+ that still loads and checks clean. A suggestion that fails a rule is
+ refused, and the refusal goes into the agent's memory, so it learns.
+- **Applying without a person.** Nothing is written until someone
+ accepts it, and a file is written only by `pyrtan grow --suggestions
+ --apply`, which puts the file back when the result does not check.
+- **Sending the conversation anywhere.** Memory, skills and the
+ conversation live in the browser's storage. What leaves it is what the
+ agent asks the swarm, what it sends to `/suggestions`, and, with a
+ server of the person's own, the prompts it sends that server.
+
+## 2. What an agent may suggest
+
+| Kind | Carries | The rules check | Written by `pyrtan grow --suggestions` as |
+| :-- | :-- | :-- | :-- |
+| `branch` | `tree`, `node`, `on`, `observed`, `meaning: {kind: done\|fail, say, reason}` | a decide node of that tree, a value no branch covers, a say of at most 300 characters reading only `inputs`, `state`, `tree`, a slug for a reason | the branch `grow` drafts by rules (the hole between ranges, `exists = false`, else `eq`), before the `else`, and a node with the agent's kind and words |
+| `trigger` | `tree`, `pattern`, `covers` | the pattern compiles, has literal words, names only the tree's inputs, is new, and reads at least one sentence it covers to this tree that did not read before, without a tie; in the page, also that it takes no sentence the agent has seen read to another tree | `[[triggers]]` after the tree's last one |
+| `tree` | `text`, `description`, `hosts_hint` | the sentence, one line, hints as strings | the scaffold `pyrtan new --from` writes, with the description, and the hints as a comment |
+
+Every suggestion also carries `why` (the model's reason, as a comment in
+the file), `by` (`agent`, `model`, `engine`) and `evidence` (the
+sentences, readings or gap it came from). A service or hub keeps them at
+`/suggestions` like gaps, the same suggestion twice as one with a count,
+at most 200, `--suggestions FILE` across restarts. `POST /suggestions`
+needs what an ask needs (`ask`, or the token): an agent's report is as
+much as its asks, which record gaps anyway. `GET /suggestions` is the
+operator's.
+
+## 3. The agent
+
+`agents.js` is one file, inlined into the console after `pyrtan.js`.
+
+- **Engines.** `WebLLMEngine` imports WebLLM from jsDelivr (pinned) only
+ when a person presses Start, and the model's weights download once
+ into the browser's cache. `OpenAIEngine` calls `/chat/completions` on
+ a URL the person gives. `ScriptedEngine` is for tests. Small models
+ are bad at JSON, so `parseJSON` finds the object in what they say
+ (fences, a preamble, a thinking block) and every caller copes with
+ none.
+- **Jobs.** `probe` (sentences for a tree, read here; the ones that reach
+ it run; misses go to `trigger`; gaps go to `meaning`), `meaning`,
+ `trigger`, `tree` (for each unanswered sentence: a tree that should
+ read it, which goes to `trigger`, or a new tree), and `patrol`, all of
+ them over every tree. Limits on sentences and runs per tree keep a
+ patrol from hammering the sites the trees read, with a pause between
+ runs. Sentences that read to nothing are never sent, so the agent does
+ not fill `/unanswered` with sentences nobody asked.
+- **Chat.** `/commands` are rules (`/ask`, `/read`, `/probe`, `/gaps`,
+ `/unanswered`, `/patrol`, `/remember`, `/memory`, `/forget`,
+ `/skills`, `/suggestions`, `/clear`). Anything else goes to the model
+ with the trees, what it remembers and the conversation; it may end its
+ reply with `ASK:`, `REMEMBER:` or `PROBE:` lines, at most three, which
+ the agent carries out; after an `ASK`, it answers again with the
+ trees' words in front of it, told to quote them as they are. Lines
+ rather than tool calls, because a one-billion-parameter model writes a
+ line far more reliably than a call.
+- **Memory**, per agent and per hub: notes (the person's, the agent's,
+ and the rules' record of what happened: refusals, verdicts and their
+ reasons, patrols, sentences that read to the wrong tree), the
+ sentences it probed and how each read, its verdicts (so a dismissed
+ suggestion is not made again), and the last forty lines of the
+ conversation. Recall is by shared words, the person's notes and the
+ newest first; it is not a model's choice either.
+- **Skills**, shared by every agent in the browser: the five built-in
+ ones are the jobs' instructions; a person's own name a job (or
+ `always`) and are added to that job's prompt, and a chat skill is
+ called as `/name`. A skill file is Markdown with `name`, `description`
+ and `when` in its front matter, as a Claude skill is, or JSON; import
+ and export are files.
+
+## 4. Paths considered and not taken
+
+**The model writes the branch.** It would be shorter to let the model
+write the TOML. Rejected: the condition is the part a rule can get right
+every time (the hole between `[1, 3]` and `[45, 48]` is `[4, 44]`), and a
+model gets it wrong often enough to matter. The model says what a value
+means, which is the part no rule can.
+
+**Tool calls.** The OpenAI tool-call format is what a large model is
+trained for. The models that run in a browser are one to four billion
+parameters; asked for JSON with a shape and for action lines, they do
+well enough, and both degrade into "nothing happened" rather than into a
+wrong action.
+
+**A model on the hub.** A hub with a model could patrol on its own. Not
+built: it would put a model on the box that runs the trees, and make
+someone pay for a GPU to run a swarm. An agent lives where a person is.
+
+**Agents that apply their own suggestions.** The organic version of
+growth would let an agent accept itself after enough evidence. Not
+built: the trees' value is that a person can read why each branch is
+there. What an agent can do is make the person's decision quick.
+
+## 5. Open questions
+
+- **Agents as members.** A trusted member already holds trees and runs
+ them; an agent could travel with it and patrol the hub's trees from
+ the device, reporting through the mailbox.
+- **Federated suggestions.** Gap reports travel the federation
+ (`--report-gaps`); suggestions for catalog trees could travel the same
+ way, to the tree's author.
+- **Synonyms.** A `[language] synonyms` suggestion would read many
+ sentences a trigger misses ("forecast" for "weather"). Not built, for
+ want of a rule that can tell a synonym from a different question.
diff --git a/_meta/design.md b/_meta/design.md index e7adb0e..e312516 100644 --- a/_meta/design.md +++ b/_meta/design.md
@@ -62,7 +62,9 @@ Four things a reader might expect, and will not find.
path, and that the path can be read. A model anywhere in the path makes the trace a story rather than a record. A model may later sit *beside* the swarm, reading gaps and proposing branches for a person to accept;
- that is an author's tool, not a runtime.
+ that is an author's tool, not a runtime. Since v0.18 it does: an agent
+ in the console, checked by rules, accepted by a person
+ ([`agents.md`](agents.md)).
- **Code in trees.** Templates are paths and filters. Conditions are comparisons. There is no expression language, no `if` in a template, no script node. Everything that branches is a `decide` node, which the trace
@@ -323,7 +325,15 @@ Five trees, all against real public APIs, all tested offline:
(`grow.py`: a missing value, the hole between ranges, else the value), `--apply` writes it into the file, and a person says what it means. Unanswered sentences persist with `--unanswered FILE`.
-10. **Later:** hosting (`platform.md` Phase 3); a serial line held open
+10. **v0.18, built: agents beside the swarm.** Open-weights models a
+ person starts in the console (WebLLM in the browser, or a server of
+ their own) probe the trees with sentences of their own, say what a
+ gap's value means, find the patterns trees missed and the trees
+ nobody wrote, chat, and keep a memory and skills. What they suggest
+ is checked by rules in the page, kept at `/suggestions`, and written
+ by `pyrtan grow --suggestions` through the same rules. Design in
+ [`agents.md`](agents.md).
+11. **Later:** hosting (`platform.md` Phase 3); a serial line held open
across the nodes of one run, for devices with a session. ## 7. Open questions diff --git a/deploy/README.md b/deploy/README.md index ce17c71..5f17da0 100644 --- a/deploy/README.md +++ b/deploy/README.md
@@ -101,6 +101,55 @@ NAS: mount the paths and pass the devices through in the compose file
drive a browser need Playwright, which the image does not carry; run those from a checkout with `uv sync --extra browser`.
+### A family intranet: a private network
+
+For a hub that serves a family (or a team) and nobody else, keep it off
+everything public: no `PYRTAN_LISTED`, no `PYRTAN_JOIN: default`, nothing
+added to pyrtan.com's `hubs.json`, no `PYRTAN_CHAT_PUBLIC` and no
+`PYRTAN_CHAT_REACH`. Make a private network instead, and share its
+invite only with the family. In the hub's environment:
+
+```yaml
+PYRTAN_TOKEN: "<long random string>" # openssl rand -hex 24; the family's way in
+PYRTAN_EGRESS: public # hubs that follow this one may ask it to run trees
+PYRTAN_ADVERTISE: "http://<nas-ip>:8700" # an address every family hub can reach (below)
+PYRTAN_CHAT: "true" # the lobby, behind the token: family only
+```
+
+Then, once, from any machine that reaches the hub:
+
+```
+pyrtan network new family --at http://<nas-ip>:8700 --token <token>
+```
+
+It prints an invite, `family:SECRET@http://<nas-ip>:8700`. Send it the
+way you would send a house key. Another family hub (a second NAS, a
+laptop running `pyrtan hub`) joins with `pyrtan join <invite> --at <that
+hub> --token <its token>`, or with the invite in its `PYRTAN_JOIN`. A
+private network's hubs never reach the public directory or the snapshot,
+and its secret signs every follow made in it. Following is one-way:
+the hub that joined follows the NAS, so its people can meet the NAS's
+in the lobby, and for the NAS's people to meet theirs, the NAS follows
+it back: `pyrtan follow http://<that hub>:8700 --network family --at
+http://<nas-ip>:8700 --token <token>`.
+
+Phones and browsers do not need a hub of their own. Open the NAS's
+console at `http://<nas-ip>:8700/`, enter the token, and chat in the
+Chat tab; join in the Member tab to keep the hub's blobs and run its
+trees.
+
+`PYRTAN_ADVERTISE` is the address the other hubs reach this one at. If
+every family hub is in one house, that is the NAS's LAN address. If
+they are in different houses, keep it off the public internet and put
+every hub and phone on one private overlay network instead, such as
+[Tailscale](https://tailscale.com) (`tailscale up` on each machine, not
+`tailscale funnel`, which is public), and advertise the NAS's address
+there (`http://<nas-name>.<tailnet>.ts.net:8700`). An internal hostname
+under a domain you own works too, if every family network resolves it.
+`PYRTAN_LAN` adds nothing here: hubs on the LAN only announce public
+networks, and the invite already carries the NAS's address, so leave
+it off and the hub needs no `network_mode: host`.
+
### Without the plugin Over SSH, from the checkout: diff --git a/pyproject.toml b/pyproject.toml index b506f9e..f4a8abd 100644 --- a/pyproject.toml +++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project] name = "pyrtan"
-version = "0.17.0"
+version = "0.18.0"
description = "Open-source total automation: a core set of expandable services that act on all forms of I/O through inspectable decision trees." readme = "README.md" license = "AGPL-3.0-or-later" diff --git a/src/pyrtan/__init__.py b/src/pyrtan/__init__.py index 7f35136..233bac2 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py
@@ -33,4 +33,4 @@ __all__ = [
"Response", ]
-__version__ = "0.17.0"
+__version__ = "0.18.0"
diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 5c053d0..0304109 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py
@@ -124,6 +124,7 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--env", action="append", default=[], metavar="K=V") p.add_argument("--gaps", metavar="FILE", help="keep gaps in this file (JSON lines) so they survive a restart") p.add_argument("--unanswered", metavar="FILE", default=os.environ.get("PYRTAN_UNANSWERED"), help="keep unanswered sentences in this file (JSON lines) so they survive a restart (default $PYRTAN_UNANSWERED)")
+ p.add_argument("--suggestions", metavar="FILE", default=os.environ.get("PYRTAN_SUGGESTIONS"), help="keep what agents suggest in this file (JSON lines) across restarts (default $PYRTAN_SUGGESTIONS)")
p.add_argument("--watch", action="store_true", default=os.environ.get("PYRTAN_WATCH", "").lower() in ("1", "true", "yes"), help="run the trees' [[watches]]: a clock, a file, a topic or a serial line starts a run (default $PYRTAN_WATCH)") p.add_argument("--egress", choices=EGRESS_POLICIES, default=os.environ.get("PYRTAN_EGRESS", "any"), help="'public' refuses private, loopback and link-local addresses (default $PYRTAN_EGRESS or any)") _access_args(p)
@@ -149,6 +150,8 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--from", dest="source", metavar="FILE", help="read gaps from a file: a --gaps JSON-lines file, a /gaps answer, or a reports.json") p.add_argument("--reports", nargs="?", const="", metavar="HUB_OR_FILE", help="read the federation's gap reports for your catalog trees (from a hub's URL, a baked reports.json, or pyrtan.com)") p.add_argument("--tree", action="append", default=[], help="only gaps of this tree (repeatable)")
+ p.add_argument("--suggestions", nargs="?", const="", metavar="HUB_OR_FILE", help="draft what agents suggested instead of gaps: from a hub's or service's URL (default --at), or a file the console saved")
+ p.add_argument("--dir", metavar="DIR", help="with --suggestions, where a suggested new tree is written (default: the first --trees directory)")
p.add_argument("--apply", action="store_true", help="write each draft into its tree file (the file must load clean afterwards)") p.add_argument("--json", action="store_true") _token_arg(p, "the token the service or hub requires, if any")
@@ -162,6 +165,7 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--env", action="append", default=[], metavar="K=V", help="a value the hub's own trees may read as {{ env.K }}") p.add_argument("--gaps", metavar="FILE", help="keep the hub's own trees' gaps in this file") p.add_argument("--unanswered", metavar="FILE", default=os.environ.get("PYRTAN_UNANSWERED"), help="keep unanswered sentences in this file across restarts (default $PYRTAN_UNANSWERED)")
+ p.add_argument("--suggestions", metavar="FILE", default=os.environ.get("PYRTAN_SUGGESTIONS"), help="keep what agents suggest in this file across restarts (default $PYRTAN_SUGGESTIONS)")
p.add_argument("--watch", action="store_true", default=os.environ.get("PYRTAN_WATCH", "").lower() in ("1", "true", "yes"), help="run the hub's own trees' [[watches]] (default $PYRTAN_WATCH)") p.add_argument("--egress", choices=EGRESS_POLICIES, default=os.environ.get("PYRTAN_EGRESS", "any"), help="for the hub's own trees: 'public' refuses private addresses") p.add_argument("--peer", action="append", default=[], help="a service URL to start with (repeatable; services may also register themselves)")
@@ -255,6 +259,11 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--scaffold", metavar="DIR", help="write a tree file for each sentence into DIR (existing files are kept)") _token_arg(p, "the token the service or hub requires, if any")
+ p = sub.add_parser("suggestions", help="what agents beside the swarm suggested: a branch's meaning, a trigger, a tree")
+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="a running service or hub (default $PYRTAN_AT or the local hub)")
+ p.add_argument("--json", action="store_true")
+ _token_arg(p, "the token the service or hub requires, if any")
+
p = sub.add_parser("new", help="scaffold a tree file") p.add_argument("name", nargs="?", help="the tree's name (derived from --from when omitted)") p.add_argument("--from", dest="sentence", metavar="TEXT", help="the sentence the tree should answer; numbers and hosts in it become slots")
@@ -346,7 +355,7 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv) commands = { "check": cmd_check, "list": cmd_list, "run": cmd_run, "match": cmd_match, "ask": cmd_ask, "chat": cmd_chat, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "unanswered": cmd_unanswered, "new": cmd_new,
- "watch": cmd_watch, "watches": cmd_watches, "grow": cmd_grow,
+ "watch": cmd_watch, "watches": cmd_watches, "grow": cmd_grow, "suggestions": cmd_suggestions,
"search": cmd_search, "pull": cmd_pull, "index": cmd_index, "publish": cmd_publish, "console": cmd_console, "follow": cmd_follow, "join": cmd_join, "network": cmd_network, "unfollow": cmd_unfollow, "peers": cmd_peers, "directory": cmd_directory, "reports": cmd_reports, "keep": cmd_keep, "kept": cmd_kept, "fetch": cmd_fetch, "id": cmd_id,
@@ -700,7 +709,7 @@ def _report(outcome: Outcome, as_json: bool, with_trace: bool) -> int:
def cmd_serve(args: argparse.Namespace) -> int: paths = _paths(args.paths) name = args.name or (paths[0].resolve().name if paths else "pyrtan")
- service, server = serve(paths, args.host, args.port, name=name, hub=args.hub, env=_kv(args.env), advertise=args.advertise, quiet=args.quiet, token=args.token, cors=args.cors, gaps=args.gaps, egress=args.egress, unanswered=args.unanswered, watch=args.watch)
+ service, server = serve(paths, args.host, args.port, name=name, hub=args.hub, env=_kv(args.env), advertise=args.advertise, quiet=args.quiet, token=args.token, cors=args.cors, gaps=args.gaps, egress=args.egress, unanswered=args.unanswered, watch=args.watch, suggestions=args.suggestions)
watching = f", watching {len(service.watcher)}" if service.watcher else "" print(f"pyrtan service {name!r}: {len(service.registry)} tree(s) at {server.url}{watching}" + (f", registered with {args.hub}" if args.hub else "") + _access_note(args), flush=True) for path, err in service.registry.errors.items():
@@ -784,6 +793,8 @@ def cmd_watches(args: argparse.Namespace) -> int:
def cmd_grow(args: argparse.Namespace) -> int: from .grow import apply_draft, draft_branch
+ if args.suggestions is not None:
+ return _grow_suggestions(args)
gaps: list[dict[str, Any]] = [] if args.at: try:
@@ -874,6 +885,143 @@ def cmd_grow(args: argparse.Namespace) -> int:
return worst
+def _load_suggestions(args: argparse.Namespace, source: str) -> list[dict[str, Any]] | None:
+ """Suggestions from a running service or hub, or a file: a /suggestions
+ answer, a list, or JSON lines."""
+ if source.startswith(("http://", "https://")):
+ try:
+ status, body = _call(args, "GET", f"{source.rstrip('/')}/suggestions")
+ except Exception as e:
+ print(f"pyrtan: {source}: {e}", file=sys.stderr)
+ return None
+ if status != 200 or not isinstance(body, dict):
+ print(f"pyrtan: {source} answered {status}" + (": pass --token, or --sign as an identity it grants operate" if status == 401 else ""), file=sys.stderr)
+ return None
+ return [s for s in body.get("suggestions", []) if isinstance(s, dict)]
+ text = Path(source).read_text(encoding="utf-8")
+ try:
+ data = json.loads(text)
+ rows = data.get("suggestions", [data]) if isinstance(data, dict) else data
+ except json.JSONDecodeError:
+ rows = [json.loads(l) for l in text.splitlines() if l.strip()]
+ return [s for s in rows if isinstance(s, dict)]
+
+
+def cmd_suggestions(args: argparse.Namespace) -> int:
+ rows = _load_suggestions(args, args.at)
+ if rows is None:
+ return 3
+ if args.json:
+ print(json.dumps({"suggestions": rows}, indent=2, ensure_ascii=False, default=str))
+ return 0
+ if not rows:
+ print("no suggestions: no agent has reported to this Pyrtan yet")
+ return 0
+ for s in rows:
+ by = s.get("by") or {}
+ who = by.get("agent", "an agent") + (f" ({by['model']})" if by.get("model") else "")
+ count = f" x{s.get('count', 1)}"
+ if s.get("kind") == "branch":
+ m = s.get("meaning") or {}
+ print(f"branch {s.get('tree')}.{s.get('node')}: {s.get('on')} = {json.dumps(s.get('observed'), ensure_ascii=False, default=str)} means {m.get('kind')}: {m.get('say')!r}{count}")
+ elif s.get("kind") == "trigger":
+ print(f"trigger {s.get('tree')}: {s.get('pattern')!r}, covering {', '.join(repr(c) for c in s.get('covers', []))}{count}")
+ else:
+ print(f"tree {s.get('text')!r}: {s.get('description') or 'no description'}{count}")
+ print(f" by {who}" + (f": {s['why']}" if s.get("why") else ""))
+ print("\nnow: pyrtan grow --suggestions " + args.at + " --trees DIR (add --apply to write them)")
+ return 0
+
+
+def _grow_suggestions(args: argparse.Namespace) -> int:
+ """``pyrtan grow --suggestions``: each suggestion drafted by the rules
+ and checked, nothing trusted because an agent said it."""
+ from .grow import apply_draft
+ from . import suggestions as sg
+
+ say = (lambda *a, **k: None) if args.json else print
+ source = args.suggestions or args.at
+ if not source:
+ print("pyrtan grow --suggestions: say where they are: a hub's URL, a file, or --at URL", file=sys.stderr)
+ return 2
+ rows = _load_suggestions(args, source)
+ if rows is None:
+ return 3
+ if args.tree:
+ rows = [s for s in rows if s.get("tree") in args.tree] # a new tree has none, so --tree leaves it out
+ reg = Registry.from_paths(_paths(args.trees))
+ for path, err in reg.errors.items():
+ print(f"pyrtan: {path}: {err}", file=sys.stderr)
+ out_dir = Path(args.dir) if args.dir else next((Path(t) for t in _paths(args.trees) if Path(t).is_dir()), Path("trees"))
+ worst, done, report = 0, 0, []
+ for s in rows:
+ why = sg.problem(s)
+ label = f"{s.get('kind')} {s.get('tree') or s.get('text')!r}"
+ if why:
+ say(f"refused {label}: {why}")
+ report.append({"suggestion": s, "refused": why})
+ continue
+ kind = s["kind"]
+ if kind == "tree":
+ name, text = sg.tree_text(s)
+ if reg.get(name):
+ say(f"skipped {label}: a tree named {name} is here already")
+ continue
+ say(f"\n# a new tree, {out_dir / (name + '.toml')}:\n{text}")
+ if args.apply:
+ try:
+ path = sg.write_tree(out_dir, s)
+ say(f"# written: {path}; now say what it observes and how it decides, then: pyrtan check {path}")
+ done += 1
+ except TreeError as e:
+ print(f"# {e}", file=sys.stderr)
+ worst = 1
+ report.append({"suggestion": s, "file": str(out_dir / (name + ".toml")), "text": text})
+ continue
+ tree = reg.get(s["tree"])
+ if tree is None:
+ say(f"skipped {label}: not among the trees here")
+ continue
+ if kind == "branch":
+ d = sg.draft(tree, s)
+ if d is None:
+ say(f"skipped {label}.{s.get('node')}: {s.get('on')} = {json.dumps(s.get('observed'), default=str)} is covered already, or names no decide node")
+ continue
+ say(f"\n{d.render()}")
+ say(f"# why: {d.reason}")
+ report.append({"suggestion": s, "file": str(tree.source), "draft": d.to_dict()})
+ if args.apply:
+ try:
+ apply_draft(tree.source, d)
+ say(f"# written into {tree.source}; read it, then: pyrtan check {tree.source}")
+ done += 1
+ except TreeError as e:
+ print(f"# {e}", file=sys.stderr)
+ worst = 1
+ else:
+ say(f"\n# {tree.name}: add after its last [[triggers]]:\n{sg.trigger_text(s)}")
+ report.append({"suggestion": s, "file": str(tree.source), "text": sg.trigger_text(s)})
+ if args.apply:
+ try:
+ sg.apply_trigger(tree.source, tree, s, reg)
+ say(f"# written into {tree.source}")
+ done += 1
+ except TreeError as e:
+ print(f"# {e}", file=sys.stderr)
+ worst = 1
+ reg.reload()
+ if args.json:
+ print(json.dumps({"drafts": report}, indent=2, ensure_ascii=False, default=str))
+ if not rows:
+ say("nothing to draft: no suggestions")
+ return 1
+ if not args.apply:
+ say("\n# nothing written; --apply puts each into its file, checked, for you to read")
+ elif done:
+ say(f"\n# {done} written; bump each tree's version when you keep it")
+ return worst
+
+
def _from_report(r: dict[str, Any]) -> dict[str, Any]: """A federation report (id namespace/name@version) as a gap (tree name).""" if "tree" in r:
@@ -895,7 +1043,7 @@ def cmd_hub(args: argparse.Namespace) -> int:
federate=args.federate or bool(args.advertise) or bool(follow) or bool(join) or args.listed or args.lan or args.chat_reach > 0, advertise=args.advertise, federation_secret=args.federation_secret, state=args.state, open_follows=not args.closed, follow=follow, join=join, listed=args.listed, trees=[Path(t) for t in args.trees], handout=[Path(t) for t in args.handout], env=_kv(args.env), gaps=args.gaps, egress=args.egress,
- store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, watch=args.watch, chat=args.chat or args.chat_public, chat_typing=args.chat_typing, chat_public=args.chat_public, lan=args.lan, chat_reach=args.chat_reach,
+ store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, suggestions=args.suggestions, watch=args.watch, chat=args.chat or args.chat_public, chat_typing=args.chat_typing, chat_public=args.chat_public, lan=args.lan, chat_reach=args.chat_reach,
allow=args.allow, grants=args.grants, ) except IdentityError as e: diff --git a/src/pyrtan/grants.py b/src/pyrtan/grants.py index 0f6ee3b..a9286d7 100644 --- a/src/pyrtan/grants.py +++ b/src/pyrtan/grants.py
@@ -44,8 +44,8 @@ def ability_for(method: str, path: str) -> str | None:
from .service import public_path if public_path(path) or path in OPEN: return None
- if path in ("/intent", "/run", "/resume") or (method == "GET" and path == "/capabilities"):
- return "ask"
+ if path in ("/intent", "/run", "/resume") or (method == "GET" and path == "/capabilities") or (method == "POST" and path == "/suggestions"):
+ return "ask" # an agent's report is as much as an ask: its asks record gaps already
if path == "/chat" or path.startswith("/chat/"): return "chat" return "operate" diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index 84f852d..005a230 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py
@@ -17,6 +17,7 @@ Endpoints:
``POST /resume`` forwarded to the peer named in the snapshot ``GET /gaps`` every peer's gaps, with the peer that saw them ``GET /unanswered`` sentences no peer answered, each with a proposed tree
+``GET /suggestions`` what agents beside the swarm sent this hub (``POST`` to send)
``GET /`` the console; ``GET /api`` is the JSON index With ``--chat``, a hub also keeps a lobby at ``/chat``, where people meet a
@@ -69,6 +70,7 @@ from . import identity
from .grants import Grants, ability_for, parse_may from .identity import IdentityError from .grow import Unanswered, no_match_outcome
+from .suggestions import Suggestions
from .intent import Trigger, read from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER from .registry import Registry
@@ -144,7 +146,7 @@ class _HubResolver:
class Hub:
- def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, chat_typing: bool = True, chat_public: bool = False, grants: Grants | None = None, audience: str | None = None, chat_reach: int = 0) -> None:
+ def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, chat_typing: bool = True, chat_public: bool = False, grants: Grants | None = None, audience: str | None = None, chat_reach: int = 0, suggestions: str | Path | None = None) -> None:
self.name = name self.grants = grants if grants is not None else Grants() self.grants.on_change = self._grant_changed
@@ -161,6 +163,7 @@ class Hub:
self.hold(local) self.federation: Federation | None = None self.unanswered = Unanswered(path=unanswered)
+ self.suggestions = Suggestions(path=suggestions) # what agents sent this hub; a hub keeps its own, it does not collect its peers'
self._reports: list[dict[str, Any]] = [] self._reports_at = 0.0 self.started = time.time()
@@ -398,6 +401,7 @@ class Hub:
"trees": sum(len(p.trees) for p in peers) + (len(self.local.trees) if self.local else 0), "local": len(self.local.trees) if self.local else 0, "unanswered": len(self.unanswered),
+ "suggestions": len(self.suggestions),
"uptime_s": int(time.time() - self.started), }
@@ -695,6 +699,8 @@ class Hub:
return 200, self.gaps() if method == "GET" and path == "/unanswered": return 200, self.unanswered_all()
+ if method in ("GET", "POST") and path == "/suggestions":
+ return self.suggestions.route(method, body)
if method == "GET" and path == "/watches": return 200, self.watches() if is_chat_path(path):
@@ -710,7 +716,7 @@ class Hub:
if method == "GET" and path == "/pyrtan.js": return 200, ("text/javascript; charset=utf-8", PYRTAN_JS) if method == "GET" and path == "/api":
- endpoints = ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/watches", "/register", "/allow", "/disallow", "/identity", "/.well-known/pyrtan/id/{id}.json"]
+ endpoints = ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/suggestions", "/watches", "/register", "/allow", "/disallow", "/identity", "/.well-known/pyrtan/id/{id}.json"]
if self.lobby is not None: endpoints += ["/chat", "/chat/start", "/chat/poll", "/chat/say", "/chat/typing", "/chat/settings", "/chat/next", "/chat/leave"] if fed is not None:
@@ -821,6 +827,7 @@ def serve_hub(
lan: bool = False, chat_reach: int = 0, chat: bool = False,
+ suggestions: str | Path | None = None,
allow: list[str] | None = None, grants: str | None = None, chat_typing: bool = True,
@@ -838,7 +845,7 @@ def serve_hub(
for who, may in specs: granted.allow(who, may) local = Service(Registry.from_paths(trees), name=f"{name}", env=env, gaps=gaps, egress=egress, watch=watch) if trees else None
- hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, chat_typing=chat_typing, chat_public=chat_public, grants=granted, chat_reach=chat_reach)
+ hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, chat_typing=chat_typing, chat_public=chat_public, grants=granted, chat_reach=chat_reach, suggestions=suggestions)
server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise), public=is_chat_path if chat_public else None, keyed=True).start() hub._audience = (advertise or server.url).rstrip("/") if federate or advertise or join or lan or chat_reach: diff --git a/src/pyrtan/js/agents.js b/src/pyrtan/js/agents.js new file mode 100644 index 0000000..5d2f044 --- /dev/null +++ b/src/pyrtan/js/agents.js
@@ -0,0 +1,627 @@
+/* agents.js: agents beside the swarm, in the browser.
+ *
+ * An agent is an open-weights model a person starts in the console. It
+ * runs in the page (WebLLM, on the browser's GPU) or on a server of the
+ * person's own that speaks the OpenAI chat API (Ollama, llama.cpp, vLLM).
+ * It is a client of the swarm like any other: it reads the trees'
+ * triggers, asks sentences through /intent, reads /gaps and /unanswered,
+ * and sends what it thinks to /suggestions. It never sits inside a run:
+ * every answer a tree gives is still the tree's, with its trace.
+ *
+ * What it does:
+ * - probes a tree with sentences of its own, read here by the same rules
+ * the swarm reads them (pyrtan.js), and runs the ones that reach the
+ * tree, so a value no branch covers becomes a real gap;
+ * - says what a gap's value means (a branch), what pattern would let a
+ * tree read sentences it missed (a trigger), and what tree a sentence
+ * nobody answered needs (a tree);
+ * - talks with the person, and asks the swarm when a tree can answer;
+ * - remembers: notes (its own and the person's), what it probed, what was
+ * accepted, dismissed or refused, and the conversation;
+ * - follows skills: instructions, built in or written by the person, for
+ * each of its jobs.
+ *
+ * What it may not do, by construction rather than by asking it nicely:
+ * every suggestion is checked here by rules (Pyrtan.grow.problem, a
+ * reading with the new pattern, a tree that still loads) before it is
+ * shown, and again by `pyrtan grow --suggestions` before it is written.
+ * No suggestion adds I/O, a host, or a read of env. A person accepts.
+ *
+ * Runs in a browser as `PyrtanAgents` (after pyrtan.js) and in node.
+ */
+(function (root, factory) {
+ if (typeof module === "object" && module.exports) module.exports = factory(require("./pyrtan.js"));
+ else root.PyrtanAgents = factory(root.Pyrtan);
+})(typeof globalThis !== "undefined" ? globalThis : this, function (Pyrtan) {
+ "use strict";
+
+ // ------------------------------------------------------------ models
+ // Open weights under open licences, as WebLLM builds them. Sizes are the
+ // download, once; the browser keeps it.
+ const WEBLLM_URL = "https://cdn.jsdelivr.net/npm/@mlc-ai/web-llm@0.2.85/+esm";
+ const MODELS = [
+ { id: "Qwen2.5-1.5B-Instruct-q4f16_1-MLC", label: "Qwen2.5 1.5B Instruct", licence: "Apache-2.0", size: "1.0 GB" },
+ { id: "Qwen3-4B-q4f16_1-MLC", label: "Qwen3 4B", licence: "Apache-2.0", size: "2.3 GB" },
+ { id: "Phi-3.5-mini-instruct-q4f16_1-MLC", label: "Phi-3.5 mini", licence: "MIT", size: "2.2 GB" },
+ { id: "SmolLM2-1.7B-Instruct-q4f16_1-MLC", label: "SmolLM2 1.7B Instruct", licence: "Apache-2.0", size: "1.0 GB" },
+ { id: "Qwen2.5-0.5B-Instruct-q4f16_1-MLC", label: "Qwen2.5 0.5B Instruct (small, rough)", licence: "Apache-2.0", size: "0.4 GB" },
+ ];
+ const DEFAULT_MODEL = MODELS[0].id;
+
+ // ------------------------------------------------------------ engines
+ // An engine is {name, model, complete(messages, {json, temperature, maxTokens}) -> text}.
+ class WebLLMEngine {
+ constructor(opts) {
+ opts = opts || {};
+ this.name = "webllm"; this.model = opts.model || DEFAULT_MODEL; this.url = opts.url || WEBLLM_URL;
+ this.onProgress = opts.onProgress || null; this.engine = null; this.loading = null;
+ }
+ static available() { return typeof navigator !== "undefined" && !!navigator.gpu; }
+ load() {
+ if (this.engine) return Promise.resolve();
+ if (!this.loading) {
+ this.loading = (async () => {
+ if (!WebLLMEngine.available()) throw new Error("this browser has no WebGPU; use a server of your own (Ollama, llama.cpp) instead");
+ const lib = await import(this.url);
+ this.engine = await lib.CreateMLCEngine(this.model, { initProgressCallback: (r) => this.onProgress && this.onProgress(r.text, r.progress) });
+ })().catch((e) => { this.loading = null; throw e; });
+ }
+ return this.loading;
+ }
+ async complete(messages, opts) {
+ opts = opts || {};
+ await this.load();
+ const req = { messages, temperature: opts.temperature === undefined ? 0.4 : opts.temperature, max_tokens: opts.maxTokens || 512 };
+ if (opts.json) req.response_format = { type: "json_object" };
+ if (/^Qwen3/.test(this.model)) req.extra_body = { enable_thinking: false };
+ const r = await this.engine.chat.completions.create(req);
+ return (r.choices[0] && r.choices[0].message && r.choices[0].message.content) || "";
+ }
+ async unload() { if (this.engine) await this.engine.unload(); this.engine = null; this.loading = null; }
+ }
+
+ // A server of the person's own: Ollama (http://localhost:11434/v1), llama.cpp, vLLM, LM Studio.
+ class OpenAIEngine {
+ constructor(opts) {
+ opts = opts || {};
+ this.name = "openai"; this.url = String(opts.url || "http://localhost:11434/v1").replace(/\/+$/, ""); this.model = opts.model || "qwen2.5:3b"; this.key = opts.key || "";
+ }
+ async load() {}
+ async complete(messages, opts) {
+ opts = opts || {};
+ const body = { model: this.model, messages, temperature: opts.temperature === undefined ? 0.4 : opts.temperature, max_tokens: opts.maxTokens || 512, stream: false };
+ if (opts.json) body.response_format = { type: "json_object" };
+ const headers = { "Content-Type": "application/json" };
+ if (this.key) headers.Authorization = "Bearer " + this.key;
+ const r = await fetch(this.url + "/chat/completions", { method: "POST", headers, body: JSON.stringify(body) });
+ if (!r.ok) throw new Error(`${this.url} answered ${r.status}${r.status === 403 || r.status === 0 ? " (does it allow this page's origin? OLLAMA_ORIGINS for Ollama)" : ""}`);
+ const d = await r.json();
+ return (d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content) || "";
+ }
+ async unload() {}
+ }
+
+ // For tests and demos: fn(messages, opts) -> text, or a list of replies in turn.
+ class ScriptedEngine {
+ constructor(fn, opts) {
+ this.name = "scripted"; this.model = (opts && opts.model) || "scripted"; this.calls = [];
+ this.fn = typeof fn === "function" ? fn : ((list) => () => (list.length > 1 ? list.shift() : list[0]))(Array.isArray(fn) ? fn.slice() : [String(fn)]);
+ }
+ async load() {}
+ async complete(messages, opts) { this.calls.push({ messages, opts }); const out = await this.fn(messages, opts || {}); return typeof out === "string" ? out : JSON.stringify(out); }
+ async unload() {}
+ }
+
+ // A model's JSON, found in what it said: fences, a preamble and a thinking block are its habits.
+ function parseJSON(text) {
+ let t = String(text || "").replace(/<think>[\s\S]*?<\/think>/g, "").replace(/```(?:json)?/g, "");
+ const start = t.search(/[{[]/);
+ if (start < 0) return null;
+ t = t.slice(start);
+ const open = t[0], close = open === "{" ? "}" : "]";
+ let depth = 0, inStr = false, esc = false;
+ for (let i = 0; i < t.length; i++) {
+ const c = t[i];
+ if (inStr) { if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false; continue; }
+ if (c === '"') inStr = true;
+ else if (c === open) depth++;
+ else if (c === close && --depth === 0) { try { return JSON.parse(t.slice(0, i + 1)); } catch (e) { return null; } }
+ }
+ return null;
+ }
+ const stripThinking = (text) => String(text || "").replace(/<think>[\s\S]*?<\/think>/g, "").trim();
+
+ // ------------------------------------------------------------ storage
+ // {get(key), set(key, value), remove(key)}, JSON values. The browser's is
+ // localStorage, per origin; nothing in it leaves the device.
+ function browserStorage(prefix) {
+ prefix = prefix || "pyrtan.agents:";
+ return {
+ get(k) { try { return JSON.parse(localStorage.getItem(prefix + k) || "null"); } catch (e) { return null; } },
+ set(k, v) { try { localStorage.setItem(prefix + k, JSON.stringify(v)); } catch (e) {} },
+ remove(k) { try { localStorage.removeItem(prefix + k); } catch (e) {} },
+ };
+ }
+ function memoryStorage() {
+ const m = new Map();
+ return { get: (k) => (m.has(k) ? JSON.parse(m.get(k)) : null), set: (k, v) => { m.set(k, JSON.stringify(v)); }, remove: (k) => { m.delete(k); }, map: m };
+ }
+
+ // ------------------------------------------------------------ memory
+ const NOTES_MAX = 100, PROBES_MAX = 60, CHAT_MAX = 40, NOTE_LEN = 300;
+ const words = (text) => new Set(String(text || "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 2));
+ class Memory {
+ constructor(storage, key, clock) {
+ this.storage = storage; this.key = key; this.clock = clock || (() => new Date());
+ const d = storage.get(key) || {};
+ this.notes = Array.isArray(d.notes) ? d.notes : []; this.probes = d.probes && typeof d.probes === "object" ? d.probes : {};
+ this.verdicts = d.verdicts && typeof d.verdicts === "object" ? d.verdicts : {}; this.chat = Array.isArray(d.chat) ? d.chat : []; this.seq = d.seq || 0;
+ }
+ save() { this.storage.set(this.key, { notes: this.notes, probes: this.probes, verdicts: this.verdicts, chat: this.chat, seq: this.seq }); }
+ now() { return Math.floor(this.clock().getTime() / 1000); }
+ // source: "you" (the person), "agent" (the model asked to), "rule" (what happened: a refusal, a verdict).
+ remember(text, source) {
+ text = String(text || "").replace(/\s+/g, " ").trim().slice(0, NOTE_LEN);
+ if (!text) return null;
+ const same = this.notes.find((n) => n.text.toLowerCase() === text.toLowerCase());
+ if (same) { same.at = this.now(); this.save(); return same; }
+ const note = { id: ++this.seq, text, source: source || "agent", at: this.now() };
+ this.notes.push(note);
+ while (this.notes.length > NOTES_MAX) {
+ const drop = this.notes.findIndex((n) => n.source !== "you"); // the person's own notes go last
+ this.notes.splice(drop < 0 ? 0 : drop, 1);
+ }
+ this.save();
+ return note;
+ }
+ forget(id) {
+ if (id === "all") { const n = this.notes.length; this.notes = []; this.save(); return n; }
+ const before = this.notes.length; this.notes = this.notes.filter((n) => String(n.id) !== String(id)); this.save(); return before - this.notes.length;
+ }
+ clear() { this.notes = []; this.probes = {}; this.verdicts = {}; this.chat = []; this.save(); }
+ // The notes that share most words with the query; the person's and the newest first among equals.
+ recall(query, n) {
+ n = n || 6;
+ const q = words(query);
+ const scored = this.notes.map((note) => { let s = 0; for (const w of words(note.text)) if (q.has(w)) s++; return { note, s }; });
+ const hits = scored.filter((x) => x.s > 0).sort((a, b) => b.s - a.s || (b.note.source === "you") - (a.note.source === "you") || b.note.at - a.note.at).map((x) => x.note);
+ const recent = this.notes.slice().sort((a, b) => b.at - a.at).filter((x) => !hits.includes(x));
+ return hits.concat(recent).slice(0, n);
+ }
+ probed(tree, entry) {
+ const list = this.probes[tree] || (this.probes[tree] = []);
+ const i = list.findIndex((p) => p.text.toLowerCase() === entry.text.toLowerCase());
+ if (i >= 0) list.splice(i, 1);
+ list.push(Object.assign({ at: this.now() }, entry));
+ while (list.length > PROBES_MAX) list.shift();
+ this.save();
+ }
+ tried(tree) { return (this.probes[tree] || []).map((p) => p.text); }
+ known() { const out = []; for (const list of Object.values(this.probes)) for (const p of list) if (p.read && p.read !== "tie") out.push([p.text, p.read]); return out; }
+ verdict(key, v, why) { this.verdicts[key] = { v, why: why || "", at: this.now() }; this.save(); }
+ decided(key) { return this.verdicts[key] ? this.verdicts[key].v : null; }
+ say(role, content) { this.chat.push({ role, content: String(content).slice(0, 2000), at: this.now() }); while (this.chat.length > CHAT_MAX) this.chat.shift(); this.save(); }
+ }
+
+ // ------------------------------------------------------------ skills
+ // A skill is instructions for a job, the way a Claude skill is: a name, a
+ // description, when it applies, and the text. The built-in ones are the
+ // jobs themselves; a person's own are added to the job they name (or to
+ // every job, "always"), and a chat skill can be called as /name.
+ const JOBS = ["chat", "probe", "meaning", "trigger", "tree"];
+ const BUILTIN = [
+ { name: "probe", when: "probe", description: "Write sentences that test whether a tree reads what people say", instructions: "Write sentences a real person might type to get what this tree gives. Mix them: plain ones; other words for the same thing; other word orders; other values for each input, including unusual but real ones; a chatty or polite one; one with a small typo. Each sentence is one line, at most 15 words. Never repeat a sentence already tried." },
+ { name: "meaning", when: "meaning", description: "Say what a value a decision met means", instructions: "A decision in a deterministic tree met a value none of its branches covers. Say what that value means to the person who asked, in one plain sentence they would want to read. If the tree can still give them an answer, it is a done; if it cannot, it is a fail with a short reason slug (lowercase words joined by dashes). Never invent facts the value does not carry: when you do not know what it means, say what was seen and that the source did not say more." },
+ { name: "trigger", when: "trigger", description: "Write a trigger pattern for sentences a tree missed", instructions: "Write the smallest pattern that reads the sentences. A pattern is literal words (case and plurals ignored), [optional words], (one|or|other) for a required choice, and {slot} or {slot:kind} for an input; kinds: text (default), word, int, float, number, date, time, duration, host, url, email. A pattern needs at least one literal word, and every slot must be one of the tree's inputs. Prefer one general pattern over many narrow ones, but never one so loose it would take sentences meant for other trees." },
+ { name: "tree", when: "tree", description: "Find the tree a sentence nobody answered needs", instructions: "A person typed a sentence no tree's trigger read. If one of the trees listed gives what they want, name it. If none does, describe in one line the tree that would, and name hosts that might answer it (an API is better than a page); a person checks them before any is listed." },
+ { name: "chat", when: "chat", description: "Talk with the person about the swarm", instructions: "Be brief and plain. When the person asks something a tree can answer, ask the swarm instead of answering from what you know, and quote the tree's answer as it is. Say so when you are guessing." },
+ ];
+ const RESERVED = new Set(["help", "ask", "read", "probe", "gaps", "unanswered", "patrol", "remember", "forget", "memory", "skills", "suggestions", "clear"].concat(BUILTIN.map((s) => s.name)));
+ function skillProblem(s) {
+ if (!s || typeof s !== "object") return "a skill is a table";
+ if (typeof s.name !== "string" || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(s.name)) return "a skill's name is lowercase letters, digits and dashes, at most 32";
+ if (RESERVED.has(s.name)) return `${s.name} is taken by a command or a built-in skill`;
+ if (!JOBS.concat(["always"]).includes(s.when || "chat")) return `when is one of ${JOBS.join(", ")}, always`;
+ if (typeof s.instructions !== "string" || !s.instructions.trim() || s.instructions.length > 4000) return "a skill has instructions, at most 4000 characters";
+ if (s.description !== undefined && (typeof s.description !== "string" || s.description.length > 200)) return "description is one line";
+ return null;
+ }
+ // A skill written as Markdown with front matter (name, description, when), as Claude skills are; or JSON.
+ function parseSkills(text) {
+ text = String(text || "").trim();
+ if (text.startsWith("{") || text.startsWith("[")) {
+ const d = JSON.parse(text);
+ return Array.isArray(d) ? d : Array.isArray(d.skills) ? d.skills : [d];
+ }
+ const m = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/.exec(text);
+ if (!m) throw new Error("a skill file starts with --- front matter (name, description, when) or is JSON");
+ const s = { instructions: m[2].trim() };
+ for (const line of m[1].split("\n")) { const kv = /^(\w+)\s*:\s*(.*)$/.exec(line.trim()); if (kv) s[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim(); }
+ return [s];
+ }
+ class Skills {
+ constructor(storage, key) { this.storage = storage; this.key = key || "skills"; const d = storage.get(this.key); this.mine = Array.isArray(d) ? d.filter((s) => !skillProblem(s)) : []; }
+ save() { this.storage.set(this.key, this.mine); }
+ list() { return BUILTIN.map((s) => Object.assign({ builtin: true, on: true }, s)).concat(this.mine.map((s) => Object.assign({ builtin: false, on: s.on !== false, when: s.when || "chat" }, s))); }
+ get(name) { return this.list().find((s) => s.name === name) || null; }
+ add(s) {
+ const skill = { name: String(s.name || "").trim().toLowerCase(), description: String(s.description || "").trim(), when: s.when || "chat", instructions: String(s.instructions || "").trim(), on: s.on !== false };
+ const bad = skillProblem(skill);
+ if (bad) throw new Error(bad);
+ this.mine = this.mine.filter((x) => x.name !== skill.name).concat([skill]);
+ this.save();
+ return skill;
+ }
+ remove(name) { const before = this.mine.length; this.mine = this.mine.filter((s) => s.name !== name); this.save(); return before !== this.mine.length; }
+ toggle(name, on) { const s = this.mine.find((x) => x.name === name); if (s) { s.on = on; this.save(); } return !!s; }
+ import(text) { return parseSkills(text).map((s) => this.add(s)); }
+ export() { return JSON.stringify({ "pyrtan-skills": 1, skills: this.mine }, null, 2); }
+ // The instructions for a job: the built-in one, then the person's that are on.
+ for(job) {
+ const own = BUILTIN.find((s) => s.when === job);
+ const extra = this.mine.filter((s) => s.on !== false && (s.when === job || s.when === "always"));
+ return [own ? own.instructions : ""].concat(extra.map((s) => `Skill ${s.name}${s.description ? " (" + s.description + ")" : ""}: ${s.instructions}`)).filter(Boolean).join("\n\n");
+ }
+ }
+
+ // ------------------------------------------------------------ reading, as the swarm reads
+ function candidatesOf(caps, extra) {
+ const out = [];
+ for (const t of caps.slice().sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
+ let lang;
+ try { lang = Pyrtan.intent.language(t.language || null); } catch (e) { lang = undefined; }
+ const fixed = t.trigger_inputs || {};
+ const patterns = (t.triggers || []).concat(extra && extra.tree === t.name ? [extra.pattern] : []);
+ for (const p of patterns) { try { out.push([t.name, new Pyrtan.intent.Trigger(p, fixed[p] || {}, lang)]); } catch (e) { /* a pattern the page cannot read is the swarm's problem */ } }
+ }
+ return out;
+ }
+ function brief(t) {
+ const inputs = Object.entries(t.inputs || {}).map(([n, s]) => `${n} (${s.type || "str"}${s.required ? ", required" : ""}${s.description ? ": " + s.description : ""})`);
+ return [`Tree ${t.name}: ${t.description || "no description"}`, `Answers to: ${(t.triggers || []).map((p) => JSON.stringify(p)).join(", ") || "nothing yet"}`, `Inputs: ${inputs.join("; ") || "none"}`].join("\n");
+ }
+ const pathOf = (out) => (out && out.trace && out.trace.steps ? out.trace.steps.filter((s) => !s.depth).map((s) => s.node) : []);
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+ const slug = (text, n) => String(text || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, n || 40).replace(/-+$/, "");
+
+ // ------------------------------------------------------------ the agent
+ class Agent {
+ // opts.hub(method, path, body) -> payload: the swarm, through the console's own calls.
+ // opts.raw(name) -> a tree's table, when the page holds it (a browser that is its own hub).
+ constructor(opts) {
+ opts = opts || {};
+ if (!opts.engine) throw new Error("an agent needs an engine");
+ if (!opts.hub) throw new Error("an agent needs a hub to ask");
+ this.name = slug(opts.name || "scout", 32) || "scout";
+ this.engine = opts.engine; this.hub = opts.hub; this.raw = opts.raw || (() => null);
+ this.storage = opts.storage || memoryStorage();
+ this.clock = opts.clock || (() => new Date());
+ this.memory = new Memory(this.storage, `agent:${this.name}`, this.clock);
+ this.skills = opts.skills || new Skills(this.storage);
+ this.limits = Object.assign({ probes: 6, runs: 4, gaps: 6, unanswered: 4, actions: 3, pause: 300 }, opts.limits || {});
+ this.onEvent = opts.onEvent || (() => {});
+ this.caps = null; this.suggestions = []; this.stopping = false; this.seq = 0;
+ }
+ by() { return { agent: this.name, model: String(this.engine.model || ""), engine: String(this.engine.name || "") }; }
+ emit(type, data) { try { this.onEvent(Object.assign({ type, agent: this.name }, data || {})); } catch (e) {} }
+ stop() { this.stopping = true; }
+
+ async trees(fresh) {
+ if (!this.caps || fresh) { const c = await this.hub("GET", "capabilities"); this.caps = (c && c.trees) || []; }
+ return this.caps;
+ }
+ tree(name) { return (this.caps || []).find((t) => t.name === name) || null; }
+ // How the swarm reads a sentence, here: {tree (null for none, "tie" for a tie), inputs}.
+ read(text, extra) {
+ const r = Pyrtan.read(text, candidatesOf(this.caps || [], extra), this.clock());
+ const w = r.winner;
+ return { tree: w ? w.tree : r.matches.length ? "tie" : null, inputs: w ? w.inputs : {}, cleaned: r.cleaned, ties: w ? [] : r.matches.map((m) => m.tree) };
+ }
+
+ // One call to the model for a job, with the job's skills and what it remembers.
+ async think(job, task, opts) {
+ opts = opts || {};
+ const notes = this.memory.recall(task, 6);
+ const system = [
+ `You are ${this.name}, an agent beside a Pyrtan swarm: a set of deterministic decision trees that answer sentences. You are a model (${this.engine.model || "open weights"}); the trees are not, and you never answer for them. You suggest; a person decides.`,
+ this.skills.for(job),
+ notes.length ? "What you remember:\n" + notes.map((n) => `- ${n.text}`).join("\n") : "",
+ opts.json ? "Reply with JSON only, no other text." : "",
+ ].filter(Boolean).join("\n\n");
+ const messages = [{ role: "system", content: system }].concat(opts.history || [], [{ role: "user", content: task }]);
+ this.emit("thinking", { job });
+ const text = await this.engine.complete(messages, { json: !!opts.json, temperature: opts.temperature, maxTokens: opts.maxTokens });
+ return opts.json ? parseJSON(text) : stripThinking(text);
+ }
+
+ // A suggestion that passed the rules, kept for the person to accept or dismiss.
+ offer(s, preview) {
+ const key = Pyrtan.grow.key(s);
+ if (this.memory.decided(key)) return null;
+ const found = this.suggestions.find((x) => x.key === key && x.status === "new");
+ if (found) return found;
+ const item = { id: ++this.seq, key, status: "new", suggestion: s, preview: preview || null };
+ this.suggestions.push(item);
+ this.emit("suggestion", { item });
+ return item;
+ }
+ refuse(what, why) { this.memory.remember(`refused: ${what}: ${why}`, "rule"); this.emit("refused", { what, why }); return { refused: what, why }; }
+ decide(id, verdict, why) {
+ const item = this.suggestions.find((x) => x.id === id);
+ if (!item) return null;
+ item.status = verdict;
+ this.memory.verdict(item.key, verdict, why);
+ const s = item.suggestion;
+ const what = s.kind === "branch" ? `the meaning "${s.meaning.say}" for ${s.tree}.${s.node} = ${JSON.stringify(s.observed)}` : s.kind === "trigger" ? `the pattern "${s.pattern}" for ${s.tree}` : `a new tree for "${s.text}"`;
+ this.memory.remember(`the person ${verdict} ${what}${why ? ": " + why : ""}`, "rule");
+ return item;
+ }
+ async send(items) {
+ items = (items || this.suggestions.filter((x) => x.status === "accepted")).filter(Boolean);
+ if (!items.length) return { kept: 0, refused: [] };
+ const out = await this.hub("POST", "suggestions", { suggestions: items.map((x) => x.suggestion) });
+ for (const x of items) if (!(out.refused || []).some((r) => r.index === items.indexOf(x))) x.sent = true;
+ return out;
+ }
+
+ // ---- probing a tree
+ async probe(name, count) {
+ await this.trees();
+ const t = this.tree(name);
+ if (!t) throw new Error(`no tree named ${name} here`);
+ count = Math.max(1, Math.min(Number(count) || this.limits.probes, 12));
+ const tried = this.memory.tried(name);
+ const task = `${brief(t)}\n\n${tried.length ? "Already tried, do not repeat:\n" + tried.slice(-20).map((s) => "- " + s).join("\n") + "\n\n" : ""}Write ${count} new sentences for this tree. Reply as {"sentences": ["...", "..."]}.`;
+ const got = await this.think("probe", task, { json: true, temperature: 0.9 });
+ const seen = new Set(tried.map((s) => s.toLowerCase()));
+ const sentences = (got && Array.isArray(got.sentences) ? got.sentences : []).filter((s) => typeof s === "string").map((s) => s.trim()).filter((s) => s && s.length <= 200 && !seen.has(s.toLowerCase()) && seen.add(s.toLowerCase())).slice(0, count);
+ if (!sentences.length) { this.emit("note", { text: `the model wrote no usable sentences for ${name}` }); return { tree: name, results: [], suggestions: [] }; }
+ const results = [], gaps = [];
+ let runs = 0;
+ for (const text of sentences) {
+ if (this.stopping) break;
+ const r = Object.assign({ text }, this.read(text));
+ r.read = r.tree; delete r.tree;
+ if (r.read === name && runs < this.limits.runs) {
+ runs++;
+ let out;
+ try { out = await this.hub("POST", "intent", { text }); } catch (e) { out = { status: "error", reason: "unreachable", say: String(e.message || e) }; }
+ Object.assign(r, { status: out.status, reason: out.reason || null, say: out.say || "", path: pathOf(out) });
+ for (const g of (out.trace && out.trace.gaps) || []) if (g.tree === name) gaps.push(g);
+ if (this.limits.pause) await sleep(this.limits.pause);
+ }
+ this.memory.probed(name, { text, read: r.read, status: r.status || null, reason: r.reason || null });
+ results.push(r);
+ this.emit("probe", { tree: name, result: r });
+ }
+ const offered = [];
+ const unread = results.filter((r) => r.read === null).map((r) => r.text);
+ for (const r of results) if (r.read && r.read !== name) this.memory.remember(`"${r.text}" was written for ${name} but reads as ${r.read === "tie" ? "a tie between " + r.ties.join(" and ") : r.read}`, "rule");
+ if (unread.length && !this.stopping) offered.push(...await this.triggerFor(name, unread));
+ for (const g of gaps) { if (this.stopping) break; const m = await this.meaning(g); if (m && m.id) offered.push(m); }
+ return { tree: name, results, suggestions: offered };
+ }
+
+ // ---- a pattern for sentences a tree missed, kept only if the reading says it works
+ async triggerFor(name, sentences) {
+ await this.trees();
+ const t = this.tree(name);
+ if (!t) return [];
+ const inputs = Object.keys(t.inputs || {});
+ const task = `${brief(t)}\n\nThese sentences were meant for this tree, but no trigger reads them:\n${sentences.map((s) => "- " + s).join("\n")}\n\nWrite at most 2 patterns that would. Slots may only be: ${inputs.map((n) => "{" + n + "}").join(", ") || "none (write literal words only)"}. Reply as {"patterns": [{"pattern": "...", "covers": ["...the sentences it reads..."]}], "why": "..."}.`;
+ const got = await this.think("trigger", task, { json: true, temperature: 0.3 });
+ const out = [];
+ for (const p of (got && Array.isArray(got.patterns) ? got.patterns : []).slice(0, 2)) {
+ const pattern = p && typeof p.pattern === "string" ? p.pattern.trim() : "";
+ const why = got && typeof got.why === "string" ? got.why.slice(0, 500) : "";
+ const checked = this.checkTrigger(name, pattern, sentences);
+ if (checked.why) { out.push(this.refuse(`the pattern "${pattern}" for ${name}`, checked.why)); continue; }
+ const s = { kind: "trigger", tree: name, pattern, covers: checked.covers, why, by: this.by(), evidence: checked.covers.map((text) => ({ text, read: null, inputs: checked.inputs[text] })) };
+ const bad = Pyrtan.grow.problem(s);
+ if (bad) { out.push(this.refuse(`the pattern "${pattern}" for ${name}`, bad)); continue; }
+ const item = this.offer(s, { text: `[[triggers]]\npattern = ${JSON.stringify(pattern)}`, reads: checked.inputs });
+ if (item) out.push(item);
+ }
+ return out;
+ }
+ // The rules a pattern must pass: it compiles, names only the tree's inputs, reads at least one sentence to the tree, and takes no sentence from another tree.
+ checkTrigger(name, pattern, sentences) {
+ const t = this.tree(name);
+ if (!pattern) return { why: "no pattern" };
+ let trig;
+ try { trig = new Pyrtan.intent.Trigger(pattern, {}, Pyrtan.intent.language(t.language || null)); } catch (e) { return { why: e.message }; }
+ const unknown = Object.keys(trig.slots).filter((s) => !(s in (t.inputs || {})));
+ if (unknown.length) return { why: `it names ${unknown.join(", ")}, which ${name} does not take as inputs` };
+ if ((t.triggers || []).includes(pattern)) return { why: `${name} answers to it already` };
+ const extra = { tree: name, pattern };
+ const covers = [], inputs = {};
+ for (const s of sentences) { const r = this.read(s, extra); if (r.tree === name) { covers.push(s); inputs[s] = r.inputs; } }
+ if (!covers.length) return { why: "with it, none of the sentences reads to " + name };
+ for (const [text, tree] of this.memory.known()) {
+ const r = this.read(text, extra);
+ if (r.tree !== tree && this.read(text).tree === tree) return { why: `it would take "${text}" from ${tree}` };
+ }
+ return { covers, inputs };
+ }
+
+ // ---- what a gap's value means
+ async meaning(gap) {
+ await this.trees();
+ const t = this.tree(gap.tree);
+ if (!t) return null;
+ const base = { kind: "branch", tree: gap.tree, node: gap.node, on: gap.on || null, observed: gap.observed === undefined ? null : gap.observed };
+ if (this.memory.decided(Pyrtan.grow.key(base)) || this.suggestions.some((x) => x.key === Pyrtan.grow.key(base))) return null;
+ const raw = this.raw(gap.tree);
+ const draft = raw ? Pyrtan.grow.draftBranch(raw, gap) : null;
+ if (raw && !draft) return null; // a branch covers it now
+ let context = "";
+ if (raw && raw.nodes && raw.nodes[gap.node]) {
+ const lines = (raw.nodes[gap.node].branches || []).map((b) => {
+ const target = raw.nodes[b.then || b.else] || {};
+ return `- ${b.else ? "anything else" : JSON.stringify(b.when)} -> ${b.then || b.else} (${target.kind || "?"}${target.say ? ': "' + target.say + '"' : ""})`;
+ });
+ context = `\nIts branches now:\n${lines.join("\n")}`;
+ }
+ const task = `${brief(t)}\n\nThe decision "${gap.node}" reads ${gap.on} and met ${JSON.stringify(gap.observed)}, which no branch covers.${context}\n\nSay what ${JSON.stringify(gap.observed)} means here. The say may use {{ inputs.NAME }} for these inputs: ${Object.keys(t.inputs || {}).join(", ") || "none"}${raw ? ", and {{ state.NAME }} for what the tree observed" : ""}; nothing else. Reply as {"kind": "done" or "fail", "say": "...", "reason": "a-slug", "why": "..."}.`;
+ const got = await this.think("meaning", task, { json: true, temperature: 0.3 });
+ if (!got || typeof got !== "object") return this.refuse(`a meaning for ${gap.tree}.${gap.node} = ${JSON.stringify(gap.observed)}`, "the model's reply was not JSON");
+ const meaning = { kind: got.kind === "done" ? "done" : "fail", say: typeof got.say === "string" ? got.say.trim().slice(0, 300) : "" };
+ if (meaning.kind === "fail") meaning.reason = slug(got.reason || got.say, 40) || "unhandled";
+ const s = Object.assign({}, base, { meaning, why: typeof got.why === "string" ? got.why.slice(0, 500) : "", by: this.by(), evidence: [{ gap: { node: gap.node, on: gap.on, observed: gap.observed } }] });
+ const bad = Pyrtan.grow.problem(s);
+ if (bad) return this.refuse(`the meaning "${meaning.say}" for ${gap.tree}.${gap.node}`, bad);
+ let preview = null;
+ if (draft) {
+ try { new Pyrtan.Tree(Pyrtan.grow.growTree(raw, draft, meaning)); } catch (e) { return this.refuse(`the meaning for ${gap.tree}.${gap.node}`, `the tree would not load: ${e.message}`); }
+ preview = { draft, text: `# in [nodes.${draft.node}], before the else:\n ${draft.branch}\n\n[nodes.${draft.then}]\nkind = "${meaning.kind}"${meaning.reason ? `\nreason = "${meaning.reason}"` : ""}\nsay = ${JSON.stringify(meaning.say)}` };
+ }
+ return this.offer(s, preview);
+ }
+ async meanings(limit) {
+ const g = await this.hub("GET", "gaps");
+ const out = [];
+ for (const gap of ((g && g.gaps) || []).slice(0, limit || this.limits.gaps)) { if (this.stopping) break; const m = await this.meaning(gap); if (m) out.push(m); }
+ return out;
+ }
+
+ // ---- sentences nobody answered
+ async unanswered(limit) {
+ await this.trees();
+ const u = await this.hub("GET", "unanswered");
+ const out = [];
+ for (const e of ((u && u.unanswered) || []).slice(0, limit || this.limits.unanswered)) {
+ if (this.stopping) break;
+ const asTree = { kind: "tree", text: e.text };
+ if (this.memory.decided(Pyrtan.grow.key(asTree))) continue;
+ const list = (this.caps || []).map((t) => `- ${t.name}: ${t.description || ""}`).join("\n");
+ const got = await this.think("tree", `The trees:\n${list || "(none)"}\n\nA person typed: ${JSON.stringify(e.text)} (${e.count || 1} times). No trigger read it. Reply as {"tree": "a name above, or null", "description": "if null: one line for the tree that would", "hosts": ["..."], "why": "..."}.`, { json: true, temperature: 0.3 });
+ if (!got) { out.push(this.refuse(`a tree for "${e.text}"`, "the model's reply was not JSON")); continue; }
+ if (got.tree && this.tree(String(got.tree))) { out.push(...await this.triggerFor(String(got.tree), [e.text])); continue; }
+ const s = { kind: "tree", text: e.text, description: typeof got.description === "string" ? got.description.trim().slice(0, 200) : "", hosts_hint: (Array.isArray(got.hosts) ? got.hosts : []).filter((h) => typeof h === "string").map((h) => h.trim().slice(0, 100)).slice(0, 5), why: typeof got.why === "string" ? got.why.slice(0, 500) : "", by: this.by(), evidence: [{ text: e.text, count: e.count || 1 }] };
+ const bad = Pyrtan.grow.problem(s);
+ if (bad) { out.push(this.refuse(`a tree for "${e.text}"`, bad)); continue; }
+ const p = Pyrtan.propose(e.text);
+ const item = this.offer(s, { text: `${p.scaffold}\n# name ${p.name}, answering "${p.trigger}"${s.description ? "\n# " + s.description : ""}` });
+ if (item) out.push(item);
+ }
+ return out;
+ }
+
+ // ---- everything, once: each tree probed, then the gaps, then the unanswered
+ async patrol(opts) {
+ opts = opts || {};
+ this.stopping = false;
+ await this.trees(true);
+ const names = (opts.trees || this.caps.map((t) => t.name)).slice(0, opts.maxTrees || 12);
+ const report = { agent: this.name, model: this.by().model, started: Math.floor(this.clock().getTime() / 1000), trees: [], suggestions: [], refused: [] };
+ const take = (list) => { for (const x of list || []) { if (x && x.refused) report.refused.push(x); else if (x) report.suggestions.push(x); } };
+ for (const name of names) {
+ if (this.stopping) break;
+ this.emit("patrol", { step: "probe", tree: name });
+ try { const p = await this.probe(name, opts.probes); report.trees.push({ tree: name, results: p.results }); take(p.suggestions); }
+ catch (e) { report.trees.push({ tree: name, error: String(e.message || e) }); }
+ }
+ if (!this.stopping) { this.emit("patrol", { step: "gaps" }); try { take(await this.meanings()); } catch (e) { report.error = String(e.message || e); } }
+ if (!this.stopping) { this.emit("patrol", { step: "unanswered" }); try { take(await this.unanswered()); } catch (e) { report.error = String(e.message || e); } }
+ report.stopped = this.stopping; this.stopping = false;
+ const tally = report.trees.reduce((a, t) => { for (const r of t.results || []) { a.sentences++; if (r.read === t.tree) a.read++; if (r.read === null) a.unread++; if (r.read && r.read !== t.tree) a.misread++; if (r.reason === "gap") a.gaps++; } return a; }, { sentences: 0, read: 0, unread: 0, misread: 0, gaps: 0 });
+ report.tally = tally;
+ this.memory.remember(`patrol: ${tally.sentences} sentences over ${report.trees.length} trees; ${tally.read} read, ${tally.unread} unread, ${tally.misread} misread, ${tally.gaps} gaps; ${report.suggestions.length} suggestions`, "rule");
+ this.emit("patrol", { step: "done", report });
+ return report;
+ }
+
+ // ---- talking with the person
+ // "/command args" is done by rules; anything else goes to the model,
+ // which may end its reply with lines the agent acts on: ASK: a sentence
+ // for the swarm, REMEMBER: a note, PROBE: a tree.
+ async chat(text) {
+ text = String(text || "").trim();
+ if (!text) return { say: "" };
+ if (text.startsWith("/")) return this.command(text);
+ return this.converse(text, null);
+ }
+ async converse(text, skill) {
+ await this.trees();
+ const job = "chat";
+ const list = (this.caps || []).slice(0, 30).map((t) => `- ${t.name}: ${t.description || ""} (e.g. ${JSON.stringify((t.triggers || [])[0] || "")})`).join("\n");
+ const task = [
+ `The trees this swarm holds:\n${list || "(none yet)"}`,
+ skill ? `Use the skill ${skill.name} now: ${skill.instructions}` : "",
+ `To act, end your reply with lines of their own (at most ${this.limits.actions}):\nASK: <a sentence for the swarm, when a tree can answer it>\nREMEMBER: <a short note worth keeping across conversations>\nPROBE: <a tree name, to test it with sentences of your own>`,
+ `The person says: ${text}`,
+ ].filter(Boolean).join("\n\n");
+ const history = this.memory.chat.slice(-12).map((m) => ({ role: m.role === "you" ? "user" : "assistant", content: m.content }));
+ this.memory.say("you", text);
+ const reply = await this.think(job, task, { history, temperature: 0.5, maxTokens: 400 });
+ const actions = [];
+ const say = reply.split("\n").filter((line) => {
+ const m = /^\s*(ASK|REMEMBER|PROBE)\s*:\s*(.+?)\s*$/i.exec(line);
+ if (!m) return true;
+ if (actions.length < this.limits.actions) actions.push({ kind: m[1].toLowerCase(), input: m[2].replace(/^["']|["']$/g, "") });
+ return false;
+ }).join("\n").trim();
+ const answers = [];
+ for (const a of actions) {
+ if (a.kind === "remember") { a.result = this.memory.remember(a.input, "agent"); }
+ else if (a.kind === "ask") {
+ try { const out = await this.hub("POST", "intent", { text: a.input }); a.result = { status: out.status, tree: out.tree, reason: out.reason || null, say: out.say || "", path: pathOf(out) }; } catch (e) { a.result = { status: "error", say: String(e.message || e) }; }
+ answers.push(`${JSON.stringify(a.input)} -> ${a.result.tree || "no tree"} (${a.result.status}${a.result.reason ? ", " + a.result.reason : ""}): ${a.result.say}`);
+ } else if (a.kind === "probe") {
+ if (this.tree(a.input)) { const p = await this.probe(a.input); a.result = { probed: p.results.length, suggestions: p.suggestions.filter((x) => x && x.id).length }; }
+ else a.result = { error: `no tree named ${a.input}` };
+ }
+ this.emit("action", { action: a });
+ }
+ let final = say;
+ if (answers.length) {
+ final = await this.think(job, `You asked the swarm, and its trees answered (these are their words, not yours):\n${answers.join("\n")}\n\nThe person had said: ${text}\nYou had said: ${say || "(nothing)"}\nNow answer the person in one to three sentences. Quote a tree's answer as it is; if no tree answered, say so.`, { history, temperature: 0.3, maxTokens: 300 });
+ final = final.split("\n").filter((l) => !/^\s*(ASK|REMEMBER|PROBE)\s*:/i.test(l)).join("\n").trim();
+ }
+ this.memory.say("agent", final || "(nothing)");
+ return { say: final, actions };
+ }
+ async command(text) {
+ const [, cmd, rest] = /^\/(\S+)\s*([\s\S]*)$/.exec(text) || [null, "", ""];
+ const arg = rest.trim();
+ const done = (say, extra) => Object.assign({ say, command: cmd }, extra || {});
+ switch (cmd) {
+ case "help": return done(["/ask SENTENCE: ask the swarm (a tree answers, not me)", "/read SENTENCE: how the swarm reads it, without running anything", "/probe TREE [N]: test a tree with sentences of my own", "/gaps: say what each gap's value means", "/unanswered: find the tree each unanswered sentence needs", "/patrol: all of it, every tree", "/suggestions: what I have suggested", "/remember NOTE, /memory, /forget ID|all: what I keep", "/skills: my skills; /NAME TEXT uses a chat skill of yours", "/clear: forget this conversation (notes stay)"].join("\n"));
+ case "ask": {
+ if (!arg) return done("Ask what? /ask what's the weather in Oslo");
+ const out = await this.hub("POST", "intent", { text: arg });
+ return done(`${out.tree || "no tree"} (${out.status}${out.reason ? ", " + out.reason : ""}): ${out.say || ""}`, { outcome: out });
+ }
+ case "read": {
+ await this.trees();
+ const r = this.read(arg);
+ return done(r.tree === null ? `No trigger reads ${JSON.stringify(arg)} (read as ${JSON.stringify(r.cleaned)}).` : r.tree === "tie" ? `A tie between ${r.ties.join(" and ")}.` : `${r.tree}, with ${JSON.stringify(r.inputs)} (read as ${JSON.stringify(r.cleaned)}).`, { reading: r });
+ }
+ case "probe": {
+ await this.trees();
+ const [name, n] = arg.split(/\s+/);
+ if (!name || !this.tree(name)) return done(`Probe which tree? ${(this.caps || []).map((t) => t.name).join(", ")}`);
+ const p = await this.probe(name, n);
+ const read = p.results.filter((r) => r.read === name).length;
+ return done(`${p.results.length} sentences for ${name}: ${read} read to it, ${p.results.filter((r) => r.read === null).length} read to nothing, ${p.results.filter((r) => r.read && r.read !== name).length} to something else. ${p.suggestions.filter((x) => x && x.id).length} suggestions.`, { probe: p });
+ }
+ case "gaps": { const m = await this.meanings(); return done(`${m.filter((x) => x && x.id).length} meanings suggested, ${m.filter((x) => x && x.refused).length} refused by the rules.`, { suggestions: m }); }
+ case "unanswered": { const m = await this.unanswered(); return done(`${m.filter((x) => x && x.id).length} suggestions, ${m.filter((x) => x && x.refused).length} refused by the rules.`, { suggestions: m }); }
+ case "patrol": { const r = await this.patrol(); return done(`Patrolled ${r.trees.length} trees: ${r.tally.sentences} sentences, ${r.tally.read} read, ${r.tally.unread} unread, ${r.tally.misread} misread, ${r.tally.gaps} gaps. ${r.suggestions.length} suggestions, ${r.refused.length} refused.`, { report: r }); }
+ case "suggestions": { const open = this.suggestions.filter((x) => x.status === "new"); return done(open.length ? open.map((x) => `#${x.id} ${x.suggestion.kind} ${x.suggestion.tree || x.suggestion.text}: ${x.suggestion.pattern || (x.suggestion.meaning && x.suggestion.meaning.say) || x.suggestion.description || ""}`).join("\n") : "Nothing waiting."); }
+ case "remember": { const n = this.memory.remember(arg, "you"); return done(n ? `Kept #${n.id}.` : "Remember what?"); }
+ case "memory": { const notes = this.memory.notes.slice(-30); return done(notes.length ? notes.map((n) => `#${n.id} (${n.source}) ${n.text}`).join("\n") : "Nothing kept yet."); }
+ case "forget": { const n = this.memory.forget(arg || "all"); return done(`Forgot ${n} note${n === 1 ? "" : "s"}.`); }
+ case "clear": { this.memory.chat = []; this.memory.save(); return done("This conversation is forgotten; the notes are kept."); }
+ case "skills": return done(this.skills.list().map((s) => `${s.builtin ? "built-in" : s.on ? "on" : "off"} ${s.name} (${s.when}): ${s.description || ""}`).join("\n"));
+ default: {
+ const skill = this.skills.get(cmd);
+ if (skill && !skill.builtin && skill.on && (skill.when === "chat" || skill.when === "always")) return this.converse(arg || skill.description || skill.name, skill);
+ return done(`No command or chat skill /${cmd}. /help lists them.`);
+ }
+ }
+ }
+ }
+
+ return { Agent, Memory, Skills, WebLLMEngine, OpenAIEngine, ScriptedEngine, MODELS, DEFAULT_MODEL, WEBLLM_URL, BUILTIN, JOBS, parseJSON, parseSkills, skillProblem, browserStorage, memoryStorage, candidatesOf };
+});
diff --git a/src/pyrtan/js/pyrtan.js b/src/pyrtan/js/pyrtan.js index 897cf94..5c15758 100644 --- a/src/pyrtan/js/pyrtan.js +++ b/src/pyrtan/js/pyrtan.js
@@ -19,6 +19,8 @@
* plain first). * - LocalHub makes a page a hub of its own: the paths and answers of a * service, in memory, for a browser that has joined no hub.
+ * - `grow` drafts the branch a gap asks for by grow.py's rules, and checks
+ * an agent's suggestion as suggestions.py does; it never asks a model.
* * No model. No dependencies. Runs in a browser as `Pyrtan` and in node. */
@@ -1539,6 +1541,123 @@
} // ------------------------------------------------------------ the hub in a page
+ // ------------------------------------------------------------ growing (grow.py, suggestions.py)
+ // The branch a gap asks for, by the same rules as grow.draft_branch, on a
+ // tree's raw table; and the checks suggestions.problem makes of what an
+ // agent sends. Rules only: an agent's words are data these check.
+ const tomlStr = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
+ function tomlValue(v) {
+ if (isBool(v)) return v ? "true" : "false";
+ if (isNum(v)) return Number.isInteger(v) ? String(v) : String(v);
+ if (Array.isArray(v)) return "[" + v.map(tomlValue).join(", ") + "]";
+ return '"' + tomlStr(v === null || v === undefined ? "None" : String(v)) + '"';
+ }
+ const tomlInline = (cond) => "{ " + Object.keys(cond).map((k) => `${k} = ${tomlValue(cond[k])}`).join(", ") + " }";
+ function growSlug(value, limit = 24) {
+ let text = value === null || value === undefined ? "missing" : (typeof value === "string" ? value : pyrepr(value)).toLowerCase();
+ text = text.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
+ return text.slice(0, limit).replace(/-+$/, "") || "value";
+ }
+ const safeHolds = (cond, value) => { try { return holds(cond, value); } catch (e) { return false; } };
+ function conditionFor(observed, branches) {
+ if (observed === null || observed === undefined) return [{ exists: false }, "the value was missing"];
+ if (typeof observed === "string" && /^<(str|dict|list|int|float|bool)>$/.test(observed)) return [{ type: observed.slice(1, -1) }, "a report carries a long or structured value as its type"];
+ if (isNum(observed)) {
+ const covered = [];
+ for (const b of branches) {
+ const w = isObj(b) ? b.when : null;
+ if (!isObj(w)) continue;
+ if (Array.isArray(w.between) && w.between.length === 2) { const lo = Number(w.between[0]), hi = Number(w.between[1]); if (!Number.isNaN(lo) && !Number.isNaN(hi)) covered.push([lo, hi]); }
+ else if (isNum(w.eq)) covered.push([w.eq, w.eq]);
+ }
+ if (covered.length && Number.isInteger(observed)) {
+ const below = covered.filter(([, hi]) => hi < observed).map(([, hi]) => hi), above = covered.filter(([lo]) => lo > observed).map(([lo]) => lo);
+ if (below.length && above.length && below.concat(above).every(Number.isInteger)) {
+ const b = Math.max(...below), a = Math.min(...above), lo = b + 1, hi = a - 1;
+ if (lo <= hi) return [{ between: [lo, hi] }, `the value falls in the hole between ${b} and ${a}, which the ranges around it leave open`];
+ }
+ }
+ }
+ return [{ eq: observed }, "the value itself"];
+ }
+ // raw: a tree's table (as trees.json carries it); gap: {node, on, observed}. null when the gap names no decide node or a branch covers the value.
+ function draftBranch(raw, gap) {
+ const nodes = (raw && raw.nodes) || {};
+ const id = String(gap.node === undefined ? "" : gap.node), node = nodes[id];
+ if (!isObj(node) || node.kind !== "decide") return null;
+ const on = String(gap.on || node.on || ""), observed = gap.observed === undefined ? null : gap.observed;
+ const branches = (node.branches || []).filter(isObj);
+ if (branches.some((b) => "when" in b && safeHolds(b.when, observed))) return null;
+ const [when, reason] = conditionFor(observed, branches);
+ const base = `${id}-${growSlug(observed)}`;
+ let then = base, n = 2;
+ while (has(nodes, then)) then = `${base}-${n++}`;
+ return { tree: raw.name, node: id, on, observed, when, then, branch: `{ when = ${tomlInline(when)}, then = "${then}" },`, reason };
+ }
+ // The tree a draft and a meaning make, as a new raw table: the branch before the else, the node after the rest. The caller loads it (new Tree) to see it holds.
+ function growTree(raw, draft, meaning) {
+ const out = JSON.parse(JSON.stringify(raw));
+ const node = out.nodes[draft.node];
+ const branches = node.branches || (node.branches = []);
+ const at = branches.findIndex((b) => isObj(b) && "else" in b);
+ branches.splice(at < 0 ? branches.length : at, 0, { when: draft.when, then: draft.then });
+ out.nodes[draft.then] = Object.assign({ kind: meaning.kind }, meaning.kind === "fail" ? { reason: meaning.reason || growSlug(draft.then, 40) } : {}, { say: meaning.say });
+ return out;
+ }
+
+ const SUGGESTION_KINDS = ["branch", "trigger", "tree"], SAY_ROOTS = ["inputs", "state", "tree"];
+ function suggestionProblem(s) {
+ if (!isObj(s)) return "a suggestion is a table";
+ const kind = s.kind;
+ if (!SUGGESTION_KINDS.includes(kind)) return `kind is one of ${SUGGESTION_KINDS.join(", ")}`;
+ const why = s.why === undefined ? "" : s.why;
+ if (typeof why !== "string" || why.length > 500) return "why is text of at most 500 characters";
+ const by = s.by === undefined ? {} : s.by;
+ if (!isObj(by) || !Object.values(by).every((v) => typeof v === "string" && v.length <= 120)) return "by is a table of short strings: agent, model, engine";
+ if ((kind === "branch" || kind === "trigger") && !(typeof s.tree === "string" && s.tree)) return "a branch or a trigger names its tree";
+ if (kind === "branch") {
+ if (typeof s.node !== "string" || !s.node) return "a branch names the decide node it belongs to";
+ if (!("observed" in s)) return "a branch says what value it is for (observed)";
+ const m = s.meaning;
+ if (!isObj(m) || !["done", "fail"].includes(m.kind)) return "a branch's meaning is a done or a fail";
+ if (typeof m.say !== "string" || !m.say.trim() || m.say.length > 300) return "a meaning says something, in at most 300 characters";
+ for (const mm of m.say.matchAll(PLACEHOLDER)) {
+ const head = mm[1].split("|")[0].trim().split(/[.[|\s]/)[0];
+ if (!SAY_ROOTS.includes(head)) return `say may read ${SAY_ROOTS.join(", ")}, not ${head}`;
+ }
+ if ("reason" in m && !(typeof m.reason === "string" && /^[a-z0-9][a-z0-9-]{0,39}$/.test(m.reason))) return "reason is a short lowercase slug, like no-such-place";
+ }
+ if (kind === "trigger") {
+ const pattern = s.pattern;
+ if (typeof pattern !== "string" || !pattern.trim() || pattern.length > 200) return "a trigger is a pattern of at most 200 characters";
+ try { new Trigger(pattern); } catch (e) { if (e instanceof TriggerError) return `pattern: ${e.message}`; throw e; }
+ const covers = s.covers === undefined ? [] : s.covers;
+ if (!Array.isArray(covers) || !covers.length || covers.length > 10 || !covers.every((c) => typeof c === "string" && c.length > 0 && c.length <= 200)) return "a trigger names the sentences it covers (1 to 10)";
+ }
+ if (kind === "tree") {
+ if (typeof s.text !== "string" || !s.text.trim() || s.text.length > 200) return "a tree suggestion carries the sentence it would answer";
+ const d = s.description === undefined ? "" : s.description;
+ if (typeof d !== "string" || d.length > 200) return "description is one line";
+ const hints = s.hosts_hint === undefined ? [] : s.hosts_hint;
+ if (!Array.isArray(hints) || !hints.every((h) => typeof h === "string" && h.length <= 100)) return "hosts_hint is a list of host names (a note, never a host)";
+ }
+ return null;
+ }
+ function suggestionKey(s) {
+ const parts = s.kind === "branch" ? [s.kind, s.tree, s.node, s.on === undefined ? null : s.on, s.observed === undefined ? null : s.observed]
+ : s.kind === "trigger" ? [s.kind, s.tree, s.pattern] : [s.kind, String(s.text === undefined ? "" : s.text).trim().toLowerCase()];
+ return JSON.stringify(parts);
+ }
+ const SUGGESTION_FIELDS = ["kind", "tree", "node", "on", "observed", "meaning", "pattern", "covers", "text", "description", "hosts_hint", "why", "by", "evidence"];
+ function suggestionClean(s) {
+ const out = {};
+ for (const k of SUGGESTION_FIELDS) if (has(s, k)) out[k] = s[k];
+ if (isObj(out.meaning)) { const m = {}; for (const k of ["kind", "say", "reason"]) if (has(out.meaning, k)) m[k] = out.meaning[k]; out.meaning = m; }
+ if (out.evidence !== undefined) out.evidence = Array.isArray(out.evidence) ? out.evidence.filter(isObj).slice(0, 10) : [];
+ return JSON.parse(JSON.stringify(out));
+ }
+ const grow = { draftBranch, growTree, conditionFor, problem: suggestionProblem, key: suggestionKey, clean: suggestionClean, SAY_ROOTS };
+
// A browser that has joined no hub is a hub of its own, for as long as the // page is open: it holds trees (the catalog's, as a page loads them), reads // sentences, runs the trees and keeps the gaps and the unanswered sentences
@@ -1557,7 +1676,7 @@
this.wall = opts.wall || (() => Date.now() / 1000); this.runner = new Runner({ transport: opts.transport || new FetchTransport(), adapters: opts.adapters || null, clock: this.clock, env: opts.env }); this.errors = {}; this.held = {};
- this.gaps = new Map(); this.unanswered = new Map();
+ this.gaps = new Map(); this.unanswered = new Map(); this.suggestions = new Map();
this.runs = 0; this.started = this.wall(); for (const raw of opts.trees || []) this.add(raw); }
@@ -1570,7 +1689,7 @@
} trees() { return Object.keys(this.runner.trees).sort().map((n) => this.runner.trees[n]); } health() {
- return { status: "ok", name: this.name, trees: this.trees().map((t) => t.name), errors: this.errors, runs: this.runs, gaps: this.gaps.size, unanswered: this.unanswered.size, uptime_s: Math.floor(this.wall() - this.started), watches: 0 };
+ return { status: "ok", name: this.name, trees: this.trees().map((t) => t.name), errors: this.errors, runs: this.runs, gaps: this.gaps.size, unanswered: this.unanswered.size, suggestions: this.suggestions.size, uptime_s: Math.floor(this.wall() - this.started), watches: 0 };
} capabilities() { return { name: this.name, trees: this.trees().map((t) => t.capability()) }; } noteGaps(gaps) {
@@ -1593,6 +1712,26 @@
this.unanswered.delete(oldest); } }
+ // What agents sent, as suggestions.py keeps it: checked, counted, bounded, never applied.
+ noteSuggestions(items) {
+ const now = this.wall(), refused = [];
+ let kept = 0;
+ items.forEach((s, index) => {
+ const why = suggestionProblem(s);
+ if (why) { refused.push({ index, why }); return; }
+ const c = suggestionClean(s), k = suggestionKey(c);
+ let e = this.suggestions.get(k);
+ if (!e) { e = Object.assign({}, c, { count: 0, first: now, last: now }); this.suggestions.set(k, e); }
+ else Object.assign(e, c);
+ e.count += 1; e.last = Math.max(e.last, now); kept += 1;
+ while (this.suggestions.size > UNANSWERED_LIMIT) {
+ let oldest = null;
+ for (const [kk, v] of this.suggestions) if (oldest === null || v.last < this.suggestions.get(oldest).last) oldest = kk;
+ this.suggestions.delete(oldest);
+ }
+ });
+ return { status: "ok", kept, refused };
+ }
finish(o, body) { this.runs += 1; if (o.trace && o.trace.gaps && o.trace.gaps.length) this.noteGaps(o.trace.gaps);
@@ -1619,12 +1758,19 @@
method = String(method || "GET").toUpperCase(); path = "/" + String(path || "").split("?")[0].replace(/^\/+/, ""); body = isObj(body) ? body : {};
- if (method === "GET" && path === "/api") return [200, { pyrtan: "browser", name: this.name, version: this.version, auth: "none", endpoints: ["/health", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/watches"] }];
+ if (method === "GET" && path === "/api") return [200, { pyrtan: "browser", name: this.name, version: this.version, auth: "none", endpoints: ["/health", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/suggestions", "/watches"] }];
if (method === "GET" && path === "/health") return [200, this.health()]; if (method === "GET" && path === "/capabilities") return [200, this.capabilities()]; if (method === "GET" && path === "/gaps") return [200, { gaps: [...this.gaps.values()].map((g) => Object.assign({}, g)).sort((a, b) => b.count - a.count || a.last_seen - b.last_seen) }]; if (method === "GET" && path === "/unanswered") return [200, { unanswered: [...this.unanswered.values()].map((e) => Object.assign({}, e)).sort((a, b) => b.count - a.count || b.last - a.last) }]; if (method === "GET" && path === "/watches") return [200, { count: 0, watches: [], watching: false }];
+ if (method === "GET" && path === "/suggestions") return [200, { suggestions: [...this.suggestions.values()].map((e) => Object.assign({}, e)).sort((a, b) => b.count - a.count || b.last - a.last) }];
+ if (method === "POST" && path === "/suggestions") {
+ const items = Array.isArray(body.suggestions) ? body.suggestions : body.kind ? [body] : null;
+ if (items === null) return [400, { status: "error", reason: "bad-request", say: 'Send {"suggestions": [{"kind": "branch"|"trigger"|"tree", ...}]}.' }];
+ if (items.length > 50) return [400, { status: "error", reason: "too-many", say: "At most 50 suggestions at a time." }];
+ return [200, this.noteSuggestions(items)];
+ }
if (method === "POST" && path === "/intent") return [200, await this.intent(body)]; if (method === "POST" && path === "/run") return [200, await this.run(body)]; if (method === "POST" && path === "/resume") return [200, await this.resume(body)];
@@ -1632,5 +1778,5 @@
} }
- return { MISSING, Tree, Runner, LocalHub, Reading, nlp, intent, read, propose, Trace, FetchTransport, RecordedTransport, Response, render, resolve, holds, extract, outcomeDict, fromDict, pyrepr, pyJson, html, TemplateError, TreeError, TransportError, ConditionError, SOURCES, adapterKey, version: VERSION };
+ return { MISSING, Tree, Runner, LocalHub, Reading, nlp, intent, read, propose, grow, Trace, FetchTransport, RecordedTransport, Response, render, resolve, holds, extract, outcomeDict, fromDict, pyrepr, pyJson, html, TemplateError, TreeError, TransportError, ConditionError, SOURCES, adapterKey, version: VERSION };
}); diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 252f6e8..e21e422 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py
@@ -30,6 +30,13 @@ IndexedDB) and takes the device note ``pyrtan id device add`` prints;
from then on it signs what it sends the hub with that key, and a member joined from here follows as it. A page served over plain ``http://`` to anything but localhost has no WebCrypto, holds no key, and says so.
+
+Its Agents tab starts an agent beside the swarm (``agents.js``, inlined;
+see _meta/agents.md): an open-weights model in this browser (WebLLM,
+imported only when a person presses Start) or on a server of theirs. It
+calls the same endpoints this page does, its suggestions are checked by
+rules before they are shown, and accepting one grows the tree here when
+the browser is its own hub, and keeps it at the hub's ``/suggestions``.
""" from __future__ import annotations
@@ -46,6 +53,8 @@ PYRTAN_JS = (Path(__file__).parent / "js" / "pyrtan.js").read_text(encoding="utf
THEME_JS = (Path(__file__).parent / "js" / "theme.js").read_text(encoding="utf-8") # Identifiers, canonical JSON and request signing, inlined into the console (_meta/identity.md). IDENTITY_JS = (Path(__file__).parent / "js" / "identity.js").read_text(encoding="utf-8")
+# Agents beside the swarm: open-weights models a person starts in the console (_meta/agents.md).
+AGENTS_JS = (Path(__file__).parent / "js" / "agents.js").read_text(encoding="utf-8")
PAGE = r"""<!doctype html> <html lang="en">
@@ -59,6 +68,7 @@ PAGE = r"""<!doctype html>
<meta name="theme-color" content="#c2410c"> <script>__THEME__</script> <script>__IDENTITY__</script>
+<script>__AGENTS__</script>
<style> :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --ok:#1f7a4d; --bad:#a3382b; --ask:#8a6400; --pyr:#c2410c; --pyr-fg:#fff; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9 } @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }
@@ -77,7 +87,8 @@ PAGE = r"""<!doctype html>
section[hidden] { display:none } p.lede { color:var(--mute); margin:0 0 16px } form.row { display:flex; gap:8px; margin:0 0 12px; flex-wrap:wrap }
- form.row input[type=text], form.row input[type=url], form.row input[type=password] { flex:1 1 180px; min-width:0; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) }
+ div.row { display:flex; gap:8px; flex-wrap:wrap }
+ form.row input[type=text], form.row input[type=url], form.row input[type=password], div.row input[type=text] { flex:1 1 180px; min-width:0; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) }
button { font:inherit; padding:10px 16px; border:0; border-radius:8px; background:var(--accent); color:var(--accent-fg); cursor:pointer } button.quiet { background:var(--soft); color:var(--fg) } button:disabled { opacity:.6; cursor:wait }
@@ -113,7 +124,7 @@ PAGE = r"""<!doctype html>
.chat-log .line { margin:6px 0 } .chat-log .sys { color:var(--mute); font-size:14px } .chat-log .who { font-weight:600; margin-right:6px }
- .chat-log .you .who { color:var(--accent) } .chat-log .stranger .who { color:var(--bad) } .chat-log .bot .who { color:var(--ok) }
+ .chat-log .you .who { color:var(--accent) } .chat-log .stranger .who { color:var(--bad) } .chat-log .bot .who { color:var(--ok) } .chat-log .tree .who { color:var(--tan) }
.chat-log details { font-size:13px; margin-top:2px } .typing-line { min-height:20px; color:var(--mute); font-size:14px; font-style:italic } #chat-say { flex-wrap:nowrap } #chat-say input { flex-basis:0 }
@@ -127,7 +138,7 @@ PAGE = r"""<!doctype html>
<h1><span class="pyr" data-theme-part="pyr">pyr</span><span class="tan" data-theme-part="tan">tan</span> <small id="who">…</small></h1> <span class="mute" id="where"></span> </header>
- <p class="lede">A core set of expandable services that act on all forms of I/O through inspectable decision trees. Ask a sentence a tree answers to; the answer comes back with the exact path that produced it. If nothing answers, you get the tree that would. No model is involved.</p>
+ <p class="lede">A core set of expandable services that act on all forms of I/O through inspectable decision trees. Ask a sentence a tree answers to; the answer comes back with the exact path that produced it. If nothing answers, you get the tree that would. No model is involved in an answer; an agent you start sits beside the trees, never inside them.</p>
<section id="connect" hidden> <div class="card">
@@ -152,6 +163,7 @@ PAGE = r"""<!doctype html>
<a href="#watches" data-tab="watches" id="watches-tab">Watches</a> <a href="#gaps" data-tab="gaps">Gaps</a> <a href="#unanswered" data-tab="unanswered">Unanswered</a>
+ <a href="#agents" data-tab="agents">Agents</a>
<a href="#directory" data-tab="directory" id="directory-tab" hidden>Directory</a> <a href="#member" data-tab="member" id="member-tab" hidden>Member</a> <a href="#identity" data-tab="identity" id="identity-tab" hidden>Identity</a>
@@ -210,6 +222,61 @@ PAGE = r"""<!doctype html>
<div class="card"><table id="unanswered"><thead><tr><th>Nobody answered</th><th>Would be</th><th>To write it</th></tr></thead><tbody></tbody></table></div> </section>
+ <section id="tab-agents" hidden>
+ <h2>Agents beside the swarm</h2>
+ <p class="mute" style="font-size:14px;margin-top:0">An agent is an open-weights model you start here: in this browser, on its GPU, or on a server of your own (Ollama, llama.cpp). It tests the trees with sentences of its own, says what a gap's value means, finds the patterns a tree missed and the trees nobody has written, and talks with you. It never answers for a tree and never sits inside a run: what it suggests is checked by rules here, checked again by <code>pyrtan grow --suggestions</code> before it is written, and only you accept it. It remembers what it learns and follows its skills, in this browser only. Nothing is downloaded until you start one.</p>
+ <div class="card" id="agent-setup">
+ <form class="row" id="agent-start" style="margin:0 0 8px">
+ <input type="text" id="agent-name" placeholder="a name, e.g. scout" autocomplete="off" style="flex:0 1 140px">
+ <select id="agent-engine" aria-label="where the model runs"><option value="webllm">in this browser (WebGPU)</option><option value="openai">on a server of mine (OpenAI API)</option></select>
+ <select id="agent-model" aria-label="model"></select>
+ <input type="url" id="agent-url" placeholder="http://localhost:11434/v1" autocomplete="off" hidden>
+ <input type="text" id="agent-server-model" placeholder="model, e.g. qwen2.5:3b" autocomplete="off" hidden>
+ <button type="submit" id="agent-go">Start</button>
+ </form>
+ <div class="mute" style="font-size:14px" id="agent-help">In this browser: the weights download once (from Hugging Face, through WebLLM) and stay in the browser's cache. On a server of yours, it must let this page's origin call it (Ollama: <code>OLLAMA_ORIGINS=<span class="origin-here"></span></code>).</div>
+ <div class="notice" id="agent-gpu" hidden style="margin:8px 0 0">This browser has no WebGPU, so a model cannot run in it. Point it at a server of yours instead, or use a browser that has WebGPU.</div>
+ <div class="mute" style="font-size:14px;margin-top:6px" id="agent-progress"></div>
+ </div>
+ <div id="agent-on" hidden>
+ <div class="card">
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;justify-content:space-between">
+ <div><span class="status done" id="agent-who"></span> <span class="mute" id="agent-what"></span></div>
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
+ <button type="button" id="agent-patrol" title="probe every tree, then the gaps, then the unanswered">Patrol</button>
+ <button type="button" class="quiet" id="agent-stop" disabled>Stop</button>
+ <button type="button" class="quiet" id="agent-off">Put away</button>
+ </div>
+ </div>
+ <div class="chat-log" id="agent-log" aria-live="polite" style="height:min(50vh,420px);margin-top:8px"></div>
+ <div class="typing-line" id="agent-typing" aria-live="polite"></div>
+ <form class="row" id="agent-say" style="margin:8px 0 0;flex-wrap:nowrap">
+ <input type="text" id="agent-text" placeholder="talk to the agent, or /help" autocomplete="off" maxlength="1000" style="flex-basis:0">
+ <button type="submit">Send</button>
+ </form>
+ </div>
+ <h2>Suggestions</h2>
+ <p class="mute" style="font-size:14px;margin-top:0" id="agent-sug-lede">Each passed the rules in this page. Accept one and it is kept at the hub's <code>/suggestions</code>, for <code>pyrtan grow --suggestions <span class="at">URL</span> --trees DIR --apply</code>. Dismiss one and the agent remembers why.</p>
+ <div id="agent-sugs"><p class="mute">Nothing yet. Press Patrol, or ask it to <code>/probe</code> a tree.</p></div>
+ <div style="display:flex;gap:8px;flex-wrap:wrap;margin:0 0 14px"><button type="button" class="quiet" id="agent-download">Download accepted</button></div>
+ <details class="card" id="agent-memory"><summary>Memory</summary>
+ <p class="mute" style="font-size:14px">What this agent keeps, for this hub, in this browser: your notes, its own, and what happened (refusals, your verdicts, patrols). The most relevant go into every prompt.</p>
+ <form class="row" id="agent-note-form"><input type="text" id="agent-note" placeholder="a note for the agent, e.g. Oslo's weather API is in Celsius" autocomplete="off"><button type="submit">Remember</button></form>
+ <table id="agent-notes"><tbody></tbody></table>
+ <div style="margin-top:8px"><button type="button" class="quiet" id="agent-forget-all">Forget everything</button></div>
+ </details>
+ <details class="card" id="agent-skills-box"><summary>Skills</summary>
+ <p class="mute" style="font-size:14px">Instructions for a job, the way a skill is for Claude: the built-in ones are the jobs, yours are added to the job you name (or to all of them). A chat skill of yours can be called as <code>/name</code>. Kept in this browser, shared by every agent; a skill file is Markdown with <code>name</code>, <code>description</code> and <code>when</code> in its front matter, or JSON.</p>
+ <table id="agent-skills"><thead><tr><th>Skill</th><th>For</th><th></th></tr></thead><tbody></tbody></table>
+ <form id="agent-skill-form" style="margin-top:10px">
+ <div class="row"><input type="text" id="skill-name" placeholder="name, e.g. tourist" autocomplete="off"><select id="skill-when" aria-label="for which job"><option value="chat">chat</option><option value="probe">probe</option><option value="meaning">meaning</option><option value="trigger">trigger</option><option value="tree">tree</option><option value="always">always</option></select><input type="text" id="skill-desc" placeholder="one line: what it is for" autocomplete="off"></div>
+ <textarea id="skill-body" rows="4" placeholder="the instructions, e.g. Write probe sentences the way a tourist would: short, a little misspelled, with place names in their own language." style="width:100%;font:inherit;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg)"></textarea>
+ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px"><button type="submit">Add skill</button><label class="quiet" style="padding:10px 16px;border-radius:8px;background:var(--soft);cursor:pointer">Import…<input type="file" id="skill-import" accept=".md,.json,.txt" hidden></label><button type="button" class="quiet" id="skill-export">Export mine</button></div>
+ </form>
+ </details>
+ </div>
+ </section>
+
<section id="tab-directory" hidden> <h2>Directory</h2> <p class="mute" style="font-size:14px;margin-top:0">Every hub that asked to be found, as this hub has heard it through the swarm. Entries are hints carried hub to hub; <em>link</em> is how recently each announced. Nothing here is routed to: to use one, follow it from your own hub, <code>pyrtan follow URL --at YOUR-HUB</code>. To be in it: <code>pyrtan hub --listed</code>.</p>
@@ -444,6 +511,7 @@ function showTab(name) {
if (name === "member") member.render(); if (name === "identity") ident.render(); if (name === "chat") chat.count();
+ if (name === "agents") agentsUI.render();
} async function loadDirectory() {
@@ -589,6 +657,10 @@ async function load() {
let c; try { c = await json("capabilities"); } catch (e) { problem(e.message + (a.auth === "bearer" ? " (is it the right one?)" : ""), true); return; } problem(local && !(c.trees || []).length ? "No catalog answered and none was kept in this browser, so it holds no trees yet. Join a hub above, or try again online." : "", true);
+ renderCaps(c);
+ showTab((location.hash || "#ask").slice(1));
+}
+function renderCaps(c) {
const body = $("#caps tbody"); const tries = $("#tries"); body.innerHTML = ""; tries.innerHTML = ""; suggest.from((c.trees || []).filter(t => !local || (runsHere(t) && !anySite(t))));
@@ -602,7 +674,6 @@ async function load() {
// The greyed suggestion in the empty box (Tab takes it) is one this swarm answers. const first = tries.querySelector("a[data-q]"), offered = [...tries.querySelectorAll("a[data-q]")].map(a => a.dataset.q); if (first && !offered.includes($("#text").placeholder)) $("#text").placeholder = first.dataset.q;
- showTab((location.hash || "#ask").slice(1));
} async function loadPeers() {
@@ -814,6 +885,7 @@ window.addEventListener("resize", () => suggest.ghost());
$("#tries").addEventListener("click", (e) => { const a = e.target.closest("a[data-q]"); if (!a) return; e.preventDefault(); $("#text").value = a.dataset.q; location.hash = "#ask"; ask(); }); $("#connect-form").addEventListener("submit", (e) => { e.preventDefault();
+ agentsUI.putAway();
token = $("#hub-token").value; store.set(remote ? {url: $("#hub-url").value.trim(), token} : {token}); load();
@@ -1089,13 +1161,183 @@ $("#chat-stop").addEventListener("click", (e) => { e.preventDefault(); chat.leav
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#tab-chat").hidden && chat.seat) { e.preventDefault(); chat.next(); } }); window.addEventListener("pagehide", () => chat.leave(true));
+// ---------------------------------------------------------------- agents
+// A model beside the swarm, never inside it (agents.js, _meta/agents.md).
+// It reaches the swarm only through this page's own calls, and what it
+// suggests is applied here only when you accept it, and only to the trees
+// this browser holds; a hub keeps it at /suggestions for pyrtan grow.
+const agentsUI = {
+ agent: null, config: {}, skills: null, busy: false,
+ init() {
+ try { this.config = JSON.parse(localStorage.getItem("pyrtan.agents.config") || "{}"); } catch (e) { this.config = {}; }
+ $("#agent-model").innerHTML = PyrtanAgents.MODELS.map(m => `<option value="${esc(m.id)}">${esc(m.label)} · ${esc(m.licence)} · ${esc(m.size)}</option>`).join("");
+ $("#agent-name").value = this.config.name || "scout";
+ $("#agent-engine").value = this.config.engine || (PyrtanAgents.WebLLMEngine.available() ? "webllm" : "openai");
+ $("#agent-model").value = this.config.model || PyrtanAgents.DEFAULT_MODEL;
+ $("#agent-url").value = this.config.url || "http://localhost:11434/v1";
+ $("#agent-server-model").value = this.config.serverModel || "qwen2.5:3b";
+ for (const el of document.querySelectorAll(".origin-here")) el.textContent = location.origin;
+ this.skills = new PyrtanAgents.Skills(PyrtanAgents.browserStorage("pyrtan.agents:"));
+ this.engineChanged();
+ },
+ engineChanged() {
+ const web = $("#agent-engine").value === "webllm";
+ $("#agent-model").hidden = !web; $("#agent-url").hidden = web; $("#agent-server-model").hidden = web;
+ $("#agent-gpu").hidden = !web || PyrtanAgents.WebLLMEngine.available();
+ },
+ where() { return local ? "this browser" : (base || location.origin); },
+ hub(method, path, body) { return json(path, method === "GET" ? undefined : {method, headers: {"Content-Type": "application/json"}, body: JSON.stringify(body || {})}); },
+ raw(name) { const t = local && local.runner.get(name); return t ? t.raw : null; },
+ async start() {
+ const engineKind = $("#agent-engine").value;
+ this.config = {name: $("#agent-name").value.trim() || "scout", engine: engineKind, model: $("#agent-model").value, url: $("#agent-url").value.trim(), serverModel: $("#agent-server-model").value.trim()};
+ try { localStorage.setItem("pyrtan.agents.config", JSON.stringify(this.config)); } catch (e) {}
+ const progress = (text) => { $("#agent-progress").textContent = text; };
+ const engine = engineKind === "webllm"
+ ? new PyrtanAgents.WebLLMEngine({model: this.config.model, onProgress: (text) => progress(text)})
+ : new PyrtanAgents.OpenAIEngine({url: this.config.url, model: this.config.serverModel});
+ $("#agent-go").disabled = true;
+ progress(engineKind === "webllm" ? "Loading WebLLM and the model; the first time, this is a download…" : `Asking ${this.config.url}…`);
+ try {
+ await engine.load();
+ if (engineKind === "openai") await engine.complete([{role: "user", content: "Say ok."}], {maxTokens: 4});
+ } catch (e) { progress(""); problem(`The agent could not start: ${e.message}`, true); $("#agent-go").disabled = false; return; }
+ progress("");
+ const hubKey = local ? "browser" : (base || location.origin);
+ this.agent = new PyrtanAgents.Agent({name: this.config.name, engine, hub: (m, p, b) => this.hub(m, p, b), raw: (n) => this.raw(n), storage: PyrtanAgents.browserStorage(`pyrtan.agents:${hubKey}:`), skills: this.skills, onEvent: (e) => this.event(e)});
+ $("#agent-go").disabled = false;
+ $("#agent-setup").hidden = true; $("#agent-on").hidden = false;
+ $("#agent-who").textContent = this.agent.name;
+ const m = PyrtanAgents.MODELS.find(x => x.id === engine.model);
+ $("#agent-what").textContent = `${m ? m.label : engine.model} · ${engineKind === "webllm" ? "in this browser" : "on " + this.config.url} · beside ${this.where()}`;
+ $("#agent-log").innerHTML = "";
+ for (const msg of this.agent.memory.chat.slice(-10)) this.line(msg.role === "you" ? "you" : "bot", msg.role === "you" ? "You" : this.agent.name, esc(msg.content));
+ this.sys(`${this.agent.name} is a model, not a tree: what it says is its own, and what a tree says comes with the tree's name and path. /help lists what it can do.`);
+ this.render();
+ },
+ putAway() {
+ if (!this.agent) return;
+ this.agent.stop();
+ if (this.agent.engine.unload) this.agent.engine.unload().catch(() => {});
+ this.agent = null; $("#agent-setup").hidden = false; $("#agent-on").hidden = true;
+ },
+ line(cls, who, html, extra) {
+ const log = $("#agent-log"), div = document.createElement("div");
+ div.className = "line " + cls;
+ div.innerHTML = (who ? `<span class="who">${esc(who)}:</span>` : "") + html + (extra || "");
+ log.appendChild(div); log.scrollTop = log.scrollHeight;
+ },
+ sys(text) { this.line("sys", "", esc(text)); },
+ event(e) {
+ if (e.type === "thinking") $("#agent-typing").textContent = `${e.agent} is thinking (${e.job})…`;
+ else if (e.type === "probe") { const r = e.result; this.sys(`probe ${e.tree}: “${r.text}” → ${r.read === null ? "no tree reads it" : r.read === "tie" ? "a tie" : r.read === e.tree ? (r.status ? `${r.status}${r.reason ? " (" + r.reason + ")" : ""}` : "reads, not run") : "reads as " + r.read}`); }
+ else if (e.type === "refused") this.sys(`refused by the rules: ${e.what}: ${e.why}`);
+ else if (e.type === "suggestion") { this.sys(`suggests a ${e.item.suggestion.kind} for ${e.item.suggestion.tree || "“" + e.item.suggestion.text + "”"}`); this.renderSuggestions(); }
+ else if (e.type === "patrol" && e.step !== "done") this.sys(e.step === "probe" ? `patrol: probing ${e.tree}` : `patrol: the ${e.step}`);
+ else if (e.type === "action") { const a = e.action; if (a.kind === "ask" && a.result) this.line("tree", a.result.tree || "no tree", esc(a.result.say || a.result.status), a.result.path && a.result.path.length ? `<details><summary>path</summary><code>${esc(a.result.path.join(" > "))}</code></details>` : ""); else if (a.kind === "remember") this.sys(`remembered: ${a.input}`); }
+ },
+ async run(fn) {
+ if (this.busy) return; this.busy = true; $("#agent-stop").disabled = false;
+ try { await fn(); } catch (e) { this.sys(`error: ${e.message}`); }
+ finally { this.busy = false; $("#agent-stop").disabled = true; $("#agent-typing").textContent = ""; this.renderMemory(); }
+ },
+ say(text) {
+ if (!this.agent || !text) return;
+ this.line("you", "You", esc(text));
+ return this.run(async () => {
+ const r = await this.agent.chat(text);
+ if (r.outcome) this.line("tree", r.outcome.tree || "no tree", esc(r.outcome.say || r.outcome.status), (r.outcome.trace ? `<details><summary>trace</summary><pre>${esc(renderTrace(r.outcome.trace))}</pre></details>` : ""));
+ else if (r.say) this.line("bot", this.agent.name, esc(r.say).replace(/\n/g, "<br>"));
+ });
+ },
+ patrol() { return this.run(async () => { const r = await this.agent.patrol(); this.line("bot", this.agent.name, esc(`Patrolled ${r.trees.length} trees: ${r.tally.sentences} sentences, ${r.tally.read} read, ${r.tally.unread} read to nothing, ${r.tally.misread} to another tree, ${r.tally.gaps} met a gap. ${r.suggestions.length} suggestions, ${r.refused.length} refused by the rules.${r.stopped ? " (stopped)" : ""}`)); }); },
+ // Accepting: in a browser that is its own hub, the tree grows here and now; any hub keeps the suggestion for pyrtan grow.
+ async accept(id) {
+ const item = this.agent.suggestions.find(x => x.id === id); if (!item) return;
+ const s = item.suggestion; let applied = "";
+ if (local && (s.kind === "branch" || s.kind === "trigger")) {
+ const raw = this.raw(s.tree);
+ try {
+ let grown = null;
+ if (raw && s.kind === "branch") { const d = Pyrtan.grow.draftBranch(raw, s); if (d) grown = Pyrtan.grow.growTree(raw, d, s.meaning); }
+ if (raw && s.kind === "trigger") { grown = JSON.parse(JSON.stringify(raw)); grown.triggers = (grown.triggers || []).concat([{pattern: s.pattern}]); }
+ if (grown) { new Pyrtan.Tree(grown); local.add(grown, local.held[s.tree]); applied = "grown in this page"; }
+ } catch (e) { this.sys(`could not grow ${s.tree} here: ${e.message}`); return; }
+ }
+ let kept = "";
+ try { const out = await this.agent.send([item]); kept = (out.refused || []).length ? `refused by the hub: ${out.refused[0].why}` : `kept at ${this.where()}'s /suggestions`; }
+ catch (e) { kept = `not kept: ${e.message}${/404/.test(e.message) ? " (a hub older than 0.18 has no /suggestions; download it instead)" : ""}`; }
+ this.agent.decide(id, "accepted");
+ item.note = [applied, kept].filter(Boolean).join(" · ");
+ if (applied) { try { renderCaps(await json("capabilities")); await this.agent.trees(true); } catch (e) {} }
+ this.renderSuggestions(); this.renderMemory();
+ },
+ dismiss(id) {
+ const why = prompt("Why not? The agent remembers. (optional)") || "";
+ this.agent.decide(id, "dismissed", why.trim()); this.renderSuggestions(); this.renderMemory();
+ },
+ download() {
+ const items = (this.agent ? this.agent.suggestions : []).filter(x => x.status === "accepted").map(x => x.suggestion);
+ if (!items.length) { this.sys("Nothing accepted yet."); return; }
+ const a = document.createElement("a");
+ a.href = URL.createObjectURL(new Blob([JSON.stringify({"pyrtan-suggestions": 1, suggestions: items}, null, 2)], {type: "application/json"}));
+ a.download = "pyrtan-suggestions.json"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
+ this.sys("Saved. Then: pyrtan grow --suggestions pyrtan-suggestions.json --trees DIR --apply");
+ },
+ render() { if (!this.skills) this.init(); this.renderSuggestions(); this.renderMemory(); this.renderSkills(); },
+ renderSuggestions() {
+ const box = $("#agent-sugs"); if (!box) return;
+ const items = this.agent ? this.agent.suggestions.slice().reverse() : [];
+ if (!items.length) { box.innerHTML = '<p class="mute">Nothing yet. Press Patrol, or ask it to <code>/probe</code> a tree.</p>'; return; }
+ box.innerHTML = items.map(x => {
+ const s = x.suggestion, by = s.by || {};
+ const what = s.kind === "branch" ? `<code>${esc(s.tree)}</code> · <code>${esc(s.node)}</code>: <code>${esc(s.on || "")}</code> = <code class="gap">${esc(JSON.stringify(s.observed))}</code> means a <strong>${esc(s.meaning.kind)}</strong>${s.meaning.reason ? ` <span class="mute">(${esc(s.meaning.reason)})</span>` : ""}<div class="say">${esc(s.meaning.say)}</div>`
+ : s.kind === "trigger" ? `<code>${esc(s.tree)}</code> should answer to <code>${esc(s.pattern)}</code><div class="mute" style="font-size:14px">reads ${(s.covers || []).map(c => "“" + esc(c) + "”").join(", ")}</div>`
+ : `a new tree for “${esc(s.text)}”<div class="say">${esc(s.description || "")}</div>${(s.hosts_hint || []).length ? `<div class="mute" style="font-size:14px">hosts it guessed (a note, never a host): ${esc(s.hosts_hint.join(", "))}</div>` : ""}`;
+ const state = x.status === "new" ? `<div style="display:flex;gap:8px;margin-top:8px"><button type="button" data-accept="${x.id}">Accept</button><button type="button" class="quiet" data-dismiss="${x.id}">Dismiss</button></div>` : `<div class="mute sug-state" style="font-size:14px;margin-top:6px">${esc(x.status)}${x.note ? " · " + esc(x.note) : ""}</div>`;
+ return `<div class="card"><div><span class="status asked">${esc(s.kind)}</span> <span class="mute">by ${esc(by.agent || "")}${by.model ? " · " + esc(by.model) : ""}</span></div><div style="margin-top:4px">${what}</div>${s.why ? `<div class="mute" style="font-size:14px">${esc(s.why)}</div>` : ""}${x.preview && x.preview.text ? `<details><summary>what it would write</summary><pre>${esc(x.preview.text)}</pre></details>` : ""}${state}</div>`;
+ }).join("");
+ },
+ renderMemory() {
+ const body = $("#agent-notes tbody"); if (!body) return;
+ const notes = this.agent ? this.agent.memory.notes.slice().reverse() : [];
+ body.innerHTML = notes.length ? notes.map(n => `<tr><td class="mute">${esc(n.source)}</td><td>${esc(n.text)}</td><td><button type="button" class="quiet" data-forget="${n.id}">Forget</button></td></tr>`).join("") : `<tr><td class="mute">${this.agent ? "Nothing kept yet." : "Start an agent to see what it remembers."}</td></tr>`;
+ },
+ renderSkills() {
+ const body = $("#agent-skills tbody"); if (!body || !this.skills) return;
+ body.innerHTML = this.skills.list().map(s => `<tr><td><code>${esc(s.name)}</code><div class="mute">${esc(s.description || "")}</div></td><td>${esc(s.when)}</td><td>${s.builtin ? '<span class="mute">built in</span>' : `<label><input type="checkbox" data-skill-on="${esc(s.name)}"${s.on ? " checked" : ""}> on</label> <button type="button" class="quiet" data-skill-remove="${esc(s.name)}">Remove</button>`}</td></tr>`).join("");
+ },
+};
+$("#agent-engine").addEventListener("change", () => agentsUI.engineChanged());
+$("#agent-start").addEventListener("submit", (e) => { e.preventDefault(); agentsUI.start(); });
+$("#agent-say").addEventListener("submit", (e) => { e.preventDefault(); const t = $("#agent-text").value.trim(); $("#agent-text").value = ""; agentsUI.say(t); });
+$("#agent-patrol").addEventListener("click", () => agentsUI.patrol());
+$("#agent-stop").addEventListener("click", () => { if (agentsUI.agent) { agentsUI.agent.stop(); agentsUI.sys("stopping after this step…"); } });
+$("#agent-off").addEventListener("click", () => agentsUI.putAway());
+$("#agent-download").addEventListener("click", () => agentsUI.download());
+$("#agent-sugs").addEventListener("click", (e) => { const a = e.target.closest("[data-accept]"), d = e.target.closest("[data-dismiss]"); if (a) agentsUI.accept(Number(a.dataset.accept)); if (d) agentsUI.dismiss(Number(d.dataset.dismiss)); });
+$("#agent-note-form").addEventListener("submit", (e) => { e.preventDefault(); if (agentsUI.agent && $("#agent-note").value.trim()) { agentsUI.agent.memory.remember($("#agent-note").value, "you"); $("#agent-note").value = ""; agentsUI.renderMemory(); } });
+$("#agent-notes").addEventListener("click", (e) => { const f = e.target.closest("[data-forget]"); if (f && agentsUI.agent) { agentsUI.agent.memory.forget(f.dataset.forget); agentsUI.renderMemory(); } });
+$("#agent-forget-all").addEventListener("click", () => { if (agentsUI.agent && confirm("Forget everything this agent keeps for this hub: notes, probes, verdicts and the conversation?")) { agentsUI.agent.memory.clear(); agentsUI.renderMemory(); } });
+$("#agent-skill-form").addEventListener("submit", (e) => {
+ e.preventDefault();
+ try { agentsUI.skills.add({name: $("#skill-name").value, when: $("#skill-when").value, description: $("#skill-desc").value, instructions: $("#skill-body").value}); for (const id of ["#skill-name", "#skill-desc", "#skill-body"]) $(id).value = ""; problem(""); }
+ catch (err) { problem(err.message, true); }
+ agentsUI.renderSkills();
+});
+$("#agent-skills").addEventListener("change", (e) => { const t = e.target.closest("[data-skill-on]"); if (t) agentsUI.skills.toggle(t.dataset.skillOn, t.checked); });
+$("#agent-skills").addEventListener("click", (e) => { const r = e.target.closest("[data-skill-remove]"); if (r) { agentsUI.skills.remove(r.dataset.skillRemove); agentsUI.renderSkills(); } });
+$("#skill-import").addEventListener("change", async (e) => { const f = e.target.files[0]; if (!f) return; try { const got = agentsUI.skills.import(await f.text()); problem(""); if (agentsUI.agent) agentsUI.sys(`imported ${got.map(s => s.name).join(", ")}`); } catch (err) { problem(`${f.name}: ${err.message}`, true); } e.target.value = ""; agentsUI.renderSkills(); });
+$("#skill-export").addEventListener("click", () => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([agentsUI.skills.export()], {type: "application/json"})); a.download = "pyrtan-skills.json"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); });
+agentsUI.init();
+
// Forgetting the hub makes this browser its own hub again.
-$("#forget").addEventListener("click", () => { chat.leave(true); store.clear(); base = ""; token = ""; member.stop(); $("#hub-url").value = ""; $("#hub-token").value = ""; $("#caps tbody").innerHTML = ""; $("#tries").innerHTML = ""; problem(""); if (remote) { const q = new URLSearchParams(location.search); if (q.has("hub")) { q.delete("hub"); history.replaceState(null, "", location.pathname + (q.toString() ? "?" + q : "") + location.hash); } load(); } });
+$("#forget").addEventListener("click", () => { chat.leave(true); agentsUI.putAway(); store.clear(); base = ""; token = ""; member.stop(); $("#hub-url").value = ""; $("#hub-token").value = ""; $("#caps tbody").innerHTML = ""; $("#tries").innerHTML = ""; problem(""); if (remote) { const q = new URLSearchParams(location.search); if (q.has("hub")) { q.delete("hub"); history.replaceState(null, "", location.pathname + (q.toString() ? "?" + q : "") + location.hash); } load(); } });
ident.init().finally(load); </script> </body> </html>
-""".replace("__VERSION__", __version__).replace("__THEME__", THEME_JS).replace("__IDENTITY__", IDENTITY_JS)
+""".replace("__VERSION__", __version__).replace("__THEME__", THEME_JS).replace("__IDENTITY__", IDENTITY_JS).replace("__AGENTS__", AGENTS_JS)
MANIFEST = json.dumps({ "name": "Pyrtan", diff --git a/src/pyrtan/service.py b/src/pyrtan/service.py index f10df2b..48bff16 100644 --- a/src/pyrtan/service.py +++ b/src/pyrtan/service.py
@@ -19,6 +19,10 @@ Endpoints, all JSON:
Sentences no tree answered, counted, each with a proposed tree. Each is a tree waiting to be written; ``pyrtan unanswered --at URL --scaffold DIR`` writes the files.
+``GET /suggestions`` and ``POST /suggestions`` ``{"suggestions": [...]}``
+ What agents beside the swarm suggest (:mod:`pyrtan.suggestions`): a
+ branch's meaning, a trigger, a tree. Kept, never applied;
+ ``pyrtan grow --suggestions`` writes them, by rules, for a person.
``GET /`` and ``GET /console`` The console (:mod:`pyrtan.page`); ``GET /api`` is the JSON index.
@@ -56,6 +60,7 @@ from . import __version__
from .gaps import GapStore from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER from .grow import Unanswered
+from .suggestions import Suggestions
from .registry import Registry from .runtime import Outcome, Runner from .trace import Trace
@@ -159,6 +164,7 @@ class Service:
egress: str = "any", unanswered: str | Path | None = None, watch: bool = False,
+ suggestions: str | Path | None = None,
) -> None: self.registry = registry self.name = name
@@ -171,6 +177,7 @@ class Service:
self.runner = Runner(registry, transport or UrllibTransport(egress=egress), resolver=RemoteResolver(self.hub, self.token) if self.hub else None, env=env or {}, adapters=adapters) self.gaps = gaps if isinstance(gaps, GapStore) else GapStore(gaps) self.unanswered = Unanswered(path=unanswered)
+ self.suggestions = Suggestions(path=suggestions)
self.runs = 0 self._lock = threading.Lock() self.watcher: Watcher | None = None
@@ -192,7 +199,7 @@ class Service:
def health(self) -> dict[str, Any]: self.registry.reload()
- health = {"status": "ok", "name": self.name, "trees": self.registry.names(), "errors": self.registry.errors, "runs": self.runs, "gaps": len(self.gaps), "unanswered": len(self.unanswered), "egress": self.egress, "uptime_s": int(time.time() - self.started), "watches": len(self.watcher) if self.watcher else 0}
+ health = {"status": "ok", "name": self.name, "trees": self.registry.names(), "errors": self.registry.errors, "runs": self.runs, "gaps": len(self.gaps), "unanswered": len(self.unanswered), "suggestions": len(self.suggestions), "egress": self.egress, "uptime_s": int(time.time() - self.started), "watches": len(self.watcher) if self.watcher else 0}
if self.gaps.error: health["gaps_file_error"] = self.gaps.error return health
@@ -255,6 +262,8 @@ class Service:
return 200, {"gaps": self.gaps.all()} if method == "GET" and path == "/unanswered": return 200, {"unanswered": self.unanswered.to_list()}
+ if method in ("GET", "POST") and path == "/suggestions":
+ return self.suggestions.route(method, body)
if method == "GET" and path == "/watches": return 200, self.watcher.status() if self.watcher else {"count": 0, "watches": [], "watching": False} if method == "GET" and path in ("/", "/index.html", "/console"):
@@ -266,7 +275,7 @@ class Service:
if method == "GET" and path == "/pyrtan.js": return 200, ("text/javascript; charset=utf-8", PYRTAN_JS) if method == "GET" and path == "/api":
- return 200, {"pyrtan": "service", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "endpoints": ["/health", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/watches"]}
+ return 200, {"pyrtan": "service", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "endpoints": ["/health", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/suggestions", "/watches"]}
if method == "POST" and path == "/intent": return 200, self.intent(body) if method == "POST" and path == "/run":
@@ -455,9 +464,10 @@ def serve(
egress: str = "any", unanswered: str | Path | None = None, watch: bool = False,
+ suggestions: str | Path | None = None,
) -> tuple[Service, Server]: registry = Registry.from_paths(paths)
- service = Service(registry, name=name, hub=hub, env=env, advertise=advertise, token=token, gaps=gaps, egress=egress, unanswered=unanswered, watch=watch)
+ service = Service(registry, name=name, hub=hub, env=env, advertise=advertise, token=token, gaps=gaps, egress=egress, unanswered=unanswered, watch=watch, suggestions=suggestions)
server = Server(service.route, host, port, quiet, token=token, cors=cors).start() # The server is already answering when it registers: the hub checks a new # peer's /capabilities inside /register, and would otherwise wait on a diff --git a/src/pyrtan/suggestions.py b/src/pyrtan/suggestions.py new file mode 100644 index 0000000..74b9218 --- /dev/null +++ b/src/pyrtan/suggestions.py
@@ -0,0 +1,348 @@
+"""What agents beside the swarm suggest, and how a suggestion becomes a tree.
+
+An agent is a model a person starts in the console (``agents.js``). It
+probes the trees with sentences of its own, reads the gaps and the
+unanswered sentences, and says what it thinks: a *suggestion*. It never
+sits inside a run. What it sends here is data, and nothing in this module
+trusts it: every suggestion is checked for shape when it arrives, and
+again, by rules and by :func:`pyrtan.tree.check`, when ``pyrtan grow
+--suggestions`` writes it into a file.
+
+Three kinds, and only these, because each adds no reach a tree did not
+already have:
+
+- ``branch``: what a value a ``decide`` met means. The rules of
+ :func:`pyrtan.grow.draft_branch` still choose the condition and the node
+ id; the agent supplies the node's meaning (a ``done`` or a ``fail``, its
+ ``say`` and ``reason``) in place of "nobody has said what that means".
+- ``trigger``: a pattern a tree should answer to, with the sentences it
+ covers. It is kept only if one of them then reads to that tree.
+- ``tree``: a sentence no tree answers, with a description of the tree
+ that would. The tree is still the scaffold :func:`pyrtan.grow.propose`
+ draws by rules; the agent's ``hosts_hint`` is a note, never a host.
+
+No suggestion adds an ``observe``, an ``act``, a ``delegate``, a host, a
+path, a command or a device; none may read ``env`` in what it says. A
+service or hub keeps what agents send at ``/suggestions`` (``--suggestions
+FILE`` keeps it across restarts), counted like gaps, bounded like
+unanswered sentences.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import threading
+import time
+from pathlib import Path
+from typing import Any, Iterable
+
+from .grow import Draft, _toml_str, draft_branch, propose
+from .intent import Trigger, TriggerError
+from .registry import Registry
+from .template import placeholders
+from .tree import Tree, TreeError, check, load_tree, load_tree_text
+
+KINDS = ("branch", "trigger", "tree")
+MEANINGS = ("done", "fail")
+LIMIT = 200 # distinct suggestions a store keeps
+PER_POST = 50 # suggestions one request may carry
+SAY_MAX = 300
+WHY_MAX = 500
+PATTERN_MAX = 200
+COVERS_MAX = 10
+SAY_ROOTS = ("inputs", "state", "tree") # never env: a model's words must not carry a secret out
+REASON_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
+
+
+def key(s: dict[str, Any]) -> str:
+ """What makes two suggestions the same one."""
+ kind = s.get("kind")
+ if kind == "branch":
+ parts = [kind, s.get("tree"), s.get("node"), s.get("on"), s.get("observed")]
+ elif kind == "trigger":
+ parts = [kind, s.get("tree"), s.get("pattern")]
+ else:
+ parts = [kind, str(s.get("text", "")).strip().lower()]
+ return json.dumps(parts, sort_keys=True, ensure_ascii=False, default=str)
+
+
+def problem(s: Any) -> str | None:
+ """Why ``s`` is not a suggestion this module takes, or None."""
+ if not isinstance(s, dict):
+ return "a suggestion is a table"
+ kind = s.get("kind")
+ if kind not in KINDS:
+ return f"kind is one of {', '.join(KINDS)}"
+ if not isinstance(s.get("why", ""), str) or len(s.get("why", "")) > WHY_MAX:
+ return f"why is text of at most {WHY_MAX} characters"
+ by = s.get("by", {})
+ if not isinstance(by, dict) or not all(isinstance(v, str) and len(v) <= 120 for v in by.values()):
+ return "by is a table of short strings: agent, model, engine"
+ if kind in ("branch", "trigger") and not (isinstance(s.get("tree"), str) and s["tree"]):
+ return "a branch or a trigger names its tree"
+ if kind == "branch":
+ if not isinstance(s.get("node"), str) or not s["node"]:
+ return "a branch names the decide node it belongs to"
+ if "observed" not in s:
+ return "a branch says what value it is for (observed)"
+ m = s.get("meaning")
+ if not isinstance(m, dict) or m.get("kind") not in MEANINGS:
+ return "a branch's meaning is a done or a fail"
+ say = m.get("say")
+ if not isinstance(say, str) or not say.strip() or len(say) > SAY_MAX:
+ return f"a meaning says something, in at most {SAY_MAX} characters"
+ for path in placeholders(say):
+ head = re.split(r"[.\[|\s]", path.strip(), maxsplit=1)[0]
+ if head not in SAY_ROOTS:
+ return f"say may read {', '.join(SAY_ROOTS)}, not {head}"
+ if "reason" in m and not (isinstance(m["reason"], str) and REASON_RE.match(m["reason"])):
+ return "reason is a short lowercase slug, like no-such-place"
+ if kind == "trigger":
+ pattern = s.get("pattern")
+ if not isinstance(pattern, str) or not pattern.strip() or len(pattern) > PATTERN_MAX:
+ return f"a trigger is a pattern of at most {PATTERN_MAX} characters"
+ try:
+ Trigger(pattern)
+ except TriggerError as e:
+ return f"pattern: {e}"
+ covers = s.get("covers", [])
+ if not isinstance(covers, list) or not covers or len(covers) > COVERS_MAX or not all(isinstance(c, str) and 0 < len(c) <= 200 for c in covers):
+ return f"a trigger names the sentences it covers (1 to {COVERS_MAX})"
+ if kind == "tree":
+ text = s.get("text")
+ if not isinstance(text, str) or not text.strip() or len(text) > 200:
+ return "a tree suggestion carries the sentence it would answer"
+ if not isinstance(s.get("description", ""), str) or len(s.get("description", "")) > 200:
+ return "description is one line"
+ hints = s.get("hosts_hint", [])
+ if not isinstance(hints, list) or not all(isinstance(h, str) and len(h) <= 100 for h in hints):
+ return "hosts_hint is a list of host names (a note, never a host)"
+ return None
+
+
+def clean(s: dict[str, Any]) -> dict[str, Any]:
+ """The fields a suggestion keeps; anything else an agent sent is dropped."""
+ keep = ["kind", "tree", "node", "on", "observed", "meaning", "pattern", "covers", "text", "description", "hosts_hint", "why", "by", "evidence"]
+ out = {k: s[k] for k in keep if k in s}
+ if isinstance(out.get("meaning"), dict):
+ out["meaning"] = {k: out["meaning"][k] for k in ("kind", "say", "reason") if k in out["meaning"]}
+ ev = out.get("evidence")
+ if ev is not None:
+ out["evidence"] = [e for e in ev if isinstance(e, dict)][:COVERS_MAX] if isinstance(ev, list) else []
+ return out
+
+
+class Suggestions:
+ """What agents suggested, counted: the same suggestion twice is one
+ entry seen twice. Bounded; beyond ``limit`` the least recently seen
+ goes. With a path, every suggestion is appended to a JSON-lines file
+ and read back on start."""
+
+ def __init__(self, limit: int = LIMIT, path: str | Path | None = None) -> None:
+ self.limit = limit
+ self.path = Path(path) if path else None
+ self.error: str | None = None
+ self._entries: dict[str, dict[str, Any]] = {}
+ self._lock = threading.Lock()
+ if self.path is not None and self.path.exists():
+ try:
+ for line in self.path.read_text(encoding="utf-8").splitlines():
+ try:
+ rec = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(rec, dict) and problem(rec) is None:
+ self._take(rec, float(rec.get("last", time.time())), persist=False)
+ except OSError as e:
+ self.error = str(e)
+
+ def add(self, items: Iterable[Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ """Take what an agent sent: ``(kept, refused)``, each refusal with
+ its index and why."""
+ kept, refused = [], []
+ now = time.time() # one batch, one moment: within it, the order it was sent in
+ for i, s in enumerate(items):
+ why = problem(s)
+ if why:
+ refused.append({"index": i, "why": why})
+ continue
+ kept.append(self._take(clean(s), now, persist=True))
+ return kept, refused
+
+ def _take(self, s: dict[str, Any], when: float, *, persist: bool) -> dict[str, Any]:
+ k = key(s)
+ with self._lock:
+ entry = self._entries.get(k)
+ if entry is None:
+ entry = {**clean(s), "count": 0, "first": when, "last": when}
+ self._entries[k] = entry
+ else:
+ entry.update(clean(s)) # the newest wording of the same suggestion
+ entry["count"] += int(s.get("count", 1)) if not persist else 1
+ entry["last"] = max(entry["last"], when)
+ while len(self._entries) > self.limit:
+ oldest = min(self._entries, key=lambda x: self._entries[x]["last"])
+ del self._entries[oldest]
+ if persist and self.path is not None:
+ try:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ with self.path.open("a", encoding="utf-8") as f:
+ f.write(json.dumps({**clean(s), "count": 1, "last": when}, ensure_ascii=False, default=str) + "\n")
+ self.error = None
+ except OSError as e:
+ self.error = str(e)
+ return dict(entry)
+
+ def to_list(self) -> list[dict[str, Any]]:
+ with self._lock:
+ return sorted((dict(e) for e in self._entries.values()), key=lambda e: (-e["count"], -e["last"]))
+
+ def __len__(self) -> int:
+ return len(self._entries)
+
+ def route(self, method: str, body: dict[str, Any]) -> tuple[int, Any]:
+ """``GET /suggestions`` and ``POST /suggestions``, for a service and a hub alike."""
+ if method == "GET":
+ return 200, {"suggestions": self.to_list()}
+ items = body.get("suggestions") if isinstance(body.get("suggestions"), list) else [body] if body.get("kind") else None
+ if items is None:
+ return 400, {"status": "error", "reason": "bad-request", "say": 'Send {"suggestions": [{"kind": "branch"|"trigger"|"tree", ...}]}.'}
+ if len(items) > PER_POST:
+ return 400, {"status": "error", "reason": "too-many", "say": f"At most {PER_POST} suggestions at a time."}
+ kept, refused = self.add(items)
+ return 200, {"status": "ok", "kept": len(kept), "refused": refused}
+
+
+# ---------------------------------------------------------------- writing one into a file
+
+
+def meaning_text(draft: Draft, s: dict[str, Any]) -> str:
+ """The node a branch suggestion asks for: the rules' id, the agent's words."""
+ m = s["meaning"]
+ by = s.get("by") or {}
+ who = " ".join(x for x in (by.get("agent"), f"({by['model']})" if by.get("model") else None) if x) or "an agent"
+ reason = m.get("reason") or re.sub(r"[^a-z0-9]+", "-", draft.then.lower()).strip("-")[:40] or "unhandled"
+ lines = [f"# Suggested by {who} for a gap: {draft.on} = {json.dumps(draft.observed, ensure_ascii=False)} had no branch."]
+ for why in _comment_lines(s.get("why", "")):
+ lines.append(f"# {why}")
+ lines.append(f"[nodes.{draft.then}]")
+ lines.append(f'kind = "{m["kind"]}"')
+ if m["kind"] == "fail":
+ lines.append(f'reason = "{_toml_str(reason)}"')
+ lines.append(f'say = "{_toml_str(m["say"].strip())}"')
+ return "\n".join(lines) + "\n"
+
+
+def _comment_lines(text: str, width: int = 76) -> list[str]:
+ words, lines, cur = str(text).split(), [], ""
+ for w in words:
+ if cur and len(cur) + 1 + len(w) > width:
+ lines.append(cur)
+ cur = w
+ else:
+ cur = f"{cur} {w}".strip()
+ if cur:
+ lines.append(cur)
+ return lines[:6]
+
+
+def draft(tree: Tree, s: dict[str, Any]) -> Draft | None:
+ """The branch a ``branch`` suggestion asks for, drafted by the rules,
+ with the agent's meaning in place of the rules' placeholder; None when
+ a branch already covers the value."""
+ d = draft_branch(tree, {"node": s.get("node"), "on": s.get("on"), "observed": s.get("observed")})
+ if d is None:
+ return None
+ d.node_text = meaning_text(d, s)
+ d.reason = f"{d.reason}; meaning suggested by {(s.get('by') or {}).get('agent', 'an agent')}"
+ return d
+
+
+def trigger_text(s: dict[str, Any]) -> str:
+ by = s.get("by") or {}
+ head = f"# Suggested by {by.get('agent', 'an agent')}" + (f" ({by['model']})" if by.get("model") else "") + ": covers " + "; ".join(json.dumps(c, ensure_ascii=False) for c in s.get("covers", [])[:3])
+ return f"{head}\n[[triggers]]\npattern = \"{_toml_str(s['pattern'])}\"\n"
+
+
+def apply_trigger(path: str | Path, tree: Tree, s: dict[str, Any], others: Registry | None = None) -> str:
+ """Write a ``trigger`` suggestion into the tree file, after its last
+ ``[[triggers]]``. Kept only when the file loads clean, every slot in
+ the pattern is one of the tree's inputs, and at least one sentence it
+ covers then reads to this tree (among ``others``, when given) without
+ a tie; otherwise the file is put back and :class:`TreeError` raised."""
+ path = Path(path)
+ try:
+ trig = Trigger(s["pattern"], language=tree.language)
+ except TriggerError as e:
+ raise TreeError(f"{tree.name}: the pattern does not compile ({e})") from e
+ unknown = sorted(set(trig.slots) - set(tree.inputs))
+ if unknown:
+ raise TreeError(f"{tree.name}: the pattern names {', '.join(unknown)}, which the tree does not take as inputs")
+ if s["pattern"] in [t.pattern for t in tree.triggers]:
+ raise TreeError(f"{tree.name}: already answers to {s['pattern']!r}")
+ original = path.read_text(encoding="utf-8")
+ lines = original.splitlines(keepends=True)
+ last = max((i for i, l in enumerate(lines) if l.strip() == "[[triggers]]"), default=None)
+ block = trigger_text(s)
+ if last is None:
+ text = original + ("" if original.endswith("\n") else "\n") + "\n" + block
+ else:
+ end = next((i for i in range(last + 1, len(lines)) if lines[i].lstrip().startswith("[")), len(lines))
+ while end > last + 1 and (not lines[end - 1].strip() or lines[end - 1].lstrip().startswith("#")):
+ end -= 1 # the comments and blank lines before the next table belong to it
+ text = "".join(lines[:end]) + block + "".join(lines[end:])
+ path.write_text(text, encoding="utf-8")
+ try:
+ grown = load_tree(path)
+ errors = [p for p in check(grown) if p.level == "error"]
+ if errors:
+ raise TreeError("; ".join(f"{p.where}: {p.message}" for p in errors))
+ before, after = Registry(), Registry()
+ for t in (others.trees() if others is not None else []):
+ if t.name != tree.name:
+ before.add(t)
+ after.add(t)
+ before.add(tree)
+ after.add(grown)
+ # It must read a sentence the tree did not read before. A tie has no winner, so a cover that ties does not count.
+ reads = lambda reg, c: (w := reg.read(c).winner) is not None and w.tree == tree.name # noqa: E731
+ if not any(reads(after, c) and not reads(before, c) for c in s.get("covers", [])):
+ raise TreeError("none of the sentences it covers reads to this tree with it, that did not before")
+ except TreeError as e:
+ path.write_text(original, encoding="utf-8")
+ raise TreeError(f"{path}: the trigger did not fit ({e}); nothing changed") from e
+ return text
+
+
+def tree_text(s: dict[str, Any]) -> tuple[str, str]:
+ """A ``tree`` suggestion as ``(name, file text)``: the rules' scaffold,
+ with the agent's description and a note of the hosts it guessed."""
+ p = propose(s["text"])
+ text = p.scaffold()
+ desc = str(s.get("description", "")).strip()
+ if desc:
+ text = text.replace('description = ""', f'description = "{_toml_str(desc)}"', 1)
+ hints = [h for h in s.get("hosts_hint", []) if h]
+ by = s.get("by") or {}
+ note = f"# Described by {by.get('agent', 'an agent')}" + (f" ({by['model']})" if by.get("model") else "") + "."
+ if hints:
+ note += f" It guessed these hosts might answer: {', '.join(hints)}. Check before you list any."
+ for why in _comment_lines(s.get("why", "")):
+ note += f"\n# {why}"
+ return p.name, text.replace("\n\nname = ", f"\n{note}\n\nname = ", 1)
+
+
+def write_tree(directory: str | Path, s: dict[str, Any]) -> Path:
+ """Write a ``tree`` suggestion as a new file in ``directory``; never over one."""
+ name, text = tree_text(s)
+ path = Path(directory) / f"{name}.toml"
+ if path.exists():
+ raise TreeError(f"{path} exists already; nothing changed")
+ tree = load_tree_text(text, str(path))
+ errors = [p for p in check(tree) if p.level == "error"]
+ if errors:
+ raise TreeError(f"{name}: the scaffold does not check ({'; '.join(p.message for p in errors)})")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+ return path
diff --git a/tests/agents.cjs b/tests/agents.cjs new file mode 100644 index 0000000..f72f795 --- /dev/null +++ b/tests/agents.cjs
@@ -0,0 +1,172 @@
+// agents.js against the hub in a page, with a scripted model: what an agent
+// does is checked by rules, whatever the model says.
+// node tests/agents.cjs (exit 0 and "agents ok" when every check holds)
+const assert = require("assert");
+const path = require("path");
+const root = path.resolve(__dirname, "..");
+const Pyrtan = require(path.join(root, "src/pyrtan/js/pyrtan.js"));
+const A = require(path.join(root, "src/pyrtan/js/agents.js"));
+const vectors = require(path.join(root, "tests/conformance/vectors.json"));
+const fixture = require(path.join(root, "tests/fixtures/examples.json"));
+const clock = () => new Date(vectors.clock);
+
+function swarm() {
+ const transport = new Pyrtan.RecordedTransport(fixture);
+ const hub = new Pyrtan.LocalHub({ trees: Object.values(vectors.trees), transport, adapters: transport, clock });
+ return { hub, call: async (m, p, b) => (await hub.route(m, "/" + p, b))[1] };
+}
+// The model: a reply per job, found by what the task asks.
+const model = (replies) => new A.ScriptedEngine((messages) => {
+ const task = messages[messages.length - 1].content;
+ for (const [re, reply] of replies) if (re.test(task)) return typeof reply === "function" ? reply(task, messages) : reply;
+ return "I have nothing to say.";
+});
+
+(async () => {
+ // parseJSON copes with a model's habits.
+ assert.deepStrictEqual(A.parseJSON('Sure! ```json\n{"a": [1, "}"]}\n``` hope that helps'), { a: [1, "}"] });
+ assert.deepStrictEqual(A.parseJSON("<think>{\"no\": 1}</think> {\"yes\": 2}"), { yes: 2 });
+ assert.strictEqual(A.parseJSON("no json here"), null);
+
+ // Probing: sentences read here, the ones that reach the tree run, misses become a checked pattern.
+ {
+ const { hub, call } = swarm();
+ const storage = A.memoryStorage();
+ const engine = model([
+ [/new sentences/, { sentences: ["weather in Oslo", "is it raining in Oslo", "top 3 stories on hacker news", "weather in Oslo", 7] }],
+ [/no trigger reads them/, { patterns: [{ pattern: "is it raining in {place}" }, { pattern: "is it raining in {city}" }, { pattern: "never looked at: at most two" }], why: "rain is weather" }],
+ ]);
+ const agent = new A.Agent({ name: "Scout One", engine, hub: call, clock, storage, limits: { pause: 0 } });
+ assert.strictEqual(agent.name, "scout-one");
+ const p = await agent.probe("weather", 5);
+ assert.deepStrictEqual(p.results.map((r) => [r.text, r.read]), [["weather in Oslo", "weather"], ["is it raining in Oslo", null], ["top 3 stories on hacker news", "hacker-news"]]);
+ assert.strictEqual(p.results[0].status, "done");
+ assert.ok(p.results[0].path.includes("sky"));
+ assert.strictEqual(p.results[2].status, undefined, "a sentence that reads to another tree is not run");
+ assert.strictEqual(hub.runs, 1);
+ const offered = p.suggestions.filter((x) => x.id);
+ assert.deepStrictEqual(offered.map((x) => x.suggestion.pattern), ["is it raining in {place}"]);
+ assert.deepStrictEqual(offered[0].suggestion.covers, ["is it raining in Oslo"]);
+ assert.deepStrictEqual(offered[0].preview.reads["is it raining in Oslo"], { place: "Oslo" });
+ assert.ok(p.suggestions.some((x) => x.refused && /city, which weather does not take/.test(x.why)));
+ // What it learned is in its memory, and survives a new agent on the same storage.
+ const again = new A.Agent({ name: "scout-one", engine, hub: call, clock, storage });
+ assert.ok(again.memory.notes.some((n) => /reads as hacker-news/.test(n.text)));
+ assert.ok(again.memory.notes.some((n) => n.source === "rule" && /refused/.test(n.text)));
+ assert.deepStrictEqual(again.memory.tried("weather"), ["weather in Oslo", "is it raining in Oslo", "top 3 stories on hacker news"]);
+ // Accepted, it goes to the hub's /suggestions; a dismissal is remembered with its reason.
+ agent.decide(offered[0].id, "accepted");
+ assert.deepStrictEqual(await agent.send(), { status: "ok", kept: 1, refused: [] });
+ const kept = (await call("GET", "suggestions")).suggestions;
+ assert.strictEqual(kept.length, 1);
+ assert.deepStrictEqual(kept[0].by, { agent: "scout-one", model: "scripted", engine: "scripted" });
+ assert.ok(/no literal words/.test(agent.checkTrigger("weather", "{place}", ["Oslo"]).why));
+ // A pattern that would take another tree's sentence is refused.
+ const thief = agent.checkTrigger("weather", "top {place} on hacker news", ["top 3 stories on hacker news"]);
+ assert.ok(/none of the sentences|would take/.test(thief.why), thief.why);
+ }
+
+ // A gap: the rules draft the branch, the model says what it means, the tree still loads.
+ {
+ const { call } = swarm();
+ const engine = model([[/no branch covers/, { kind: "fail", say: "Code {{ state.code }} at {{ inputs.place }} is not a weather code we know.", reason: "Odd Code!", why: "WMO codes stop at 99" }]]);
+ const agent = new A.Agent({ engine, hub: call, clock, raw: (n) => vectors.trees[n], limits: { pause: 0 } });
+ await agent.trees();
+ const item = await agent.meaning({ tree: "weather", node: "sky", on: "code", observed: 100 });
+ assert.strictEqual(item.suggestion.meaning.reason, "odd-code");
+ assert.deepStrictEqual(item.preview.draft.when, { eq: 100 });
+ assert.strictEqual(item.preview.draft.then, "sky-100");
+ const grown = Pyrtan.grow.growTree(vectors.trees.weather, item.preview.draft, item.suggestion.meaning);
+ const branches = grown.nodes.sky.branches;
+ assert.deepStrictEqual(branches[branches.length - 2], { when: { eq: 100 }, then: "sky-100" });
+ assert.ok("else" in branches[branches.length - 1]);
+ assert.strictEqual(await agent.meaning({ tree: "weather", node: "sky", on: "code", observed: 61 }), null, "a covered value asks nothing");
+ // A model that reaches for env is refused by the rules, whatever it says.
+ const sly = new A.Agent({ engine: model([[/no branch covers/, { kind: "done", say: "The key is {{ env.API_KEY }}" }]]), hub: call, clock, raw: (n) => vectors.trees[n] });
+ const r = await sly.meaning({ tree: "weather", node: "sky", on: "code", observed: 4 });
+ assert.ok(r.refused && /not env/.test(r.why), JSON.stringify(r));
+ assert.strictEqual(sly.suggestions.length, 0);
+ }
+
+ // Unanswered: a tree the model names gets a trigger; none named, a new tree, with hosts only as a hint.
+ {
+ const { call } = swarm();
+ await call("POST", "intent", { text: "make me a sandwich" });
+ const agent = new A.Agent({ engine: model([[/No trigger read it/, { tree: null, description: "Makes sandwiches.", hosts: ["sandwich.example"], why: "nothing does" }]]), hub: call, clock });
+ const out = await agent.unanswered();
+ assert.strictEqual(out.length, 1);
+ assert.deepStrictEqual([out[0].suggestion.kind, out[0].suggestion.text, out[0].suggestion.hosts_hint], ["tree", "make me a sandwich", ["sandwich.example"]]);
+ assert.ok(/pyrtan new/.test(out[0].preview.text));
+ agent.decide(out[0].id, "dismissed", "no sandwiches here");
+ assert.deepStrictEqual(await agent.unanswered(), [], "a dismissed suggestion is not made again");
+ assert.ok(agent.memory.notes.some((n) => /dismissed a new tree for "make me a sandwich": no sandwiches here/.test(n.text)));
+ }
+
+ // Chat: commands by rules; free text to the model, which acts through ASK/REMEMBER lines.
+ {
+ const { call } = swarm();
+ const engine = model([
+ [/You asked the swarm/, (task) => "The weather tree says: " + /-> weather \(done\): (.*)/.exec(task)[1]],
+ [/The person says/, "Let me ask the trees.\nASK: weather in Oslo\nREMEMBER: the person lives in Oslo\nASK: one\nASK: two\nASK: three"],
+ ]);
+ const agent = new A.Agent({ engine, hub: call, clock, limits: { actions: 2 } });
+ const r = await agent.chat("what's it like outside?");
+ assert.deepStrictEqual(r.actions.map((a) => a.kind), ["ask", "remember"]);
+ assert.strictEqual(r.actions[0].result.tree, "weather");
+ assert.ok(r.say.startsWith("The weather tree says: Oslo, Norway"));
+ assert.ok(agent.memory.notes.some((n) => n.text === "the person lives in Oslo" && n.source === "agent"));
+ assert.deepStrictEqual(agent.memory.chat.map((m) => m.role), ["you", "agent"]);
+ // The next turn carries the conversation and what it remembers.
+ await agent.chat("and tomorrow?");
+ const last = engine.calls[engine.calls.length - 1].messages;
+ assert.ok(last[0].content.includes("the person lives in Oslo"));
+ assert.ok(last.some((m) => m.role === "user" && m.content === "what's it like outside?"));
+ assert.ok((await agent.chat("/read is it raining in Bergen")).say.startsWith("No trigger reads"));
+ assert.ok((await agent.chat("/read weather in Bergen")).say.startsWith('weather, with {"place":"Bergen"}'));
+ assert.ok((await agent.chat("/ask weather in Oslo")).say.startsWith("weather (done): Oslo"));
+ assert.strictEqual((await agent.chat("/remember the units are celsius")).say, "Kept #2.");
+ assert.ok((await agent.chat("/memory")).say.includes("(you) the units are celsius"));
+ assert.ok((await agent.chat("/nope")).say.startsWith("No command or chat skill"));
+ }
+
+ // Skills: the person's own, added to the job they name, called as /name in chat.
+ {
+ const storage = A.memoryStorage();
+ const skills = new A.Skills(storage);
+ assert.throws(() => skills.add({ name: "probe", instructions: "x" }), /taken/);
+ assert.throws(() => skills.add({ name: "Bad Name", instructions: "x" }), /lowercase/);
+ assert.throws(() => skills.add({ name: "empty", instructions: " " }), /instructions/);
+ skills.add({ name: "tourist", when: "probe", description: "probe as a tourist", instructions: "Write like a tourist." });
+ const [md] = skills.import("---\nname: terse\ndescription: five words\nwhen: chat\n---\nAnswer in five words.");
+ assert.strictEqual(md.name, "terse");
+ assert.ok(skills.for("probe").includes("Skill tourist (probe as a tourist): Write like a tourist."));
+ assert.ok(!skills.for("chat").includes("tourist"));
+ skills.toggle("tourist", false);
+ assert.ok(!skills.for("probe").includes("tourist"));
+ assert.deepStrictEqual(new A.Skills(storage).list().filter((s) => !s.builtin).map((s) => [s.name, s.on]), [["tourist", false], ["terse", true]]);
+ const back = new A.Skills(A.memoryStorage());
+ back.import(skills.export());
+ assert.strictEqual(back.list().length, A.BUILTIN.length + 2);
+ const { call } = swarm();
+ const engine = model([[/Use the skill terse now/, "Sunny, mild, light wind."]]);
+ const agent = new A.Agent({ engine, hub: call, clock, skills });
+ assert.strictEqual((await agent.chat("/terse how is Oslo")).say, "Sunny, mild, light wind.");
+ assert.ok((await agent.chat("/skills")).say.includes("on terse (chat)"));
+ }
+
+ // A patrol: every tree, then the gaps, then the unanswered, in one report.
+ {
+ const { call } = swarm();
+ await call("POST", "intent", { text: "make me a sandwich" });
+ const engine = model([[/new sentences/, { sentences: [] }], [/No trigger read it/, { tree: null, description: "" }]]);
+ const agent = new A.Agent({ engine, hub: call, clock, limits: { pause: 0 } });
+ const steps = [];
+ agent.onEvent = (e) => { if (e.type === "patrol") steps.push(e.step); };
+ const report = await agent.patrol({ trees: ["weather", "github-release"] });
+ assert.deepStrictEqual(steps, ["probe", "probe", "gaps", "unanswered", "done"]);
+ assert.deepStrictEqual(report.trees.map((t) => t.tree), ["weather", "github-release"]);
+ assert.strictEqual(report.suggestions.length, 1);
+ assert.ok(agent.memory.notes.some((n) => n.text.startsWith("patrol: 0 sentences over 2 trees")));
+ }
+ console.log("agents ok");
+})().catch((e) => { console.error(e); process.exit(1); });
diff --git a/tests/browser/console_agents.cjs b/tests/browser/console_agents.cjs new file mode 100644 index 0000000..b84a9cd --- /dev/null +++ b/tests/browser/console_agents.cjs
@@ -0,0 +1,76 @@
+// The console's Agents tab, driven in Chromium.
+// node tests/browser/console_agents.cjs PAGE_URL MODEL_URL
+// PAGE_URL is a hub's console, or a static console that is its own hub;
+// MODEL_URL is an OpenAI-compatible server (the test's, scripted). Prints
+// one line per step: STARTED, PROBED <what the agent said>, SUGGESTED
+// <kinds>, ACCEPTED <the note>, ASKED <what the tree said>, LEVEL <the
+// answer to "level 5" after accepting>, KEPT <a note, after a reload>,
+// SKILL <the skills listed>, then DONE. Needs node's playwright and a Chromium.
+const { chromium } = require("playwright");
+const [page_url, model_url] = process.argv.slice(2);
+
+(async () => {
+ const browser = await chromium.launch(process.env.PYRTAN_BROWSER ? { executablePath: process.env.PYRTAN_BROWSER } : {});
+ try {
+ const page = await browser.newPage();
+ page.on("pageerror", (e) => console.log("PAGEERROR " + e.message));
+ page.on("dialog", (d) => d.accept("too vague"));
+ const start = async () => {
+ await page.goto(page_url);
+ await page.waitForFunction(() => document.querySelector("#caps tbody").children.length > 0);
+ await page.click('#tabs a[data-tab="agents"]');
+ await page.selectOption("#agent-engine", "openai");
+ await page.fill("#agent-url", model_url);
+ await page.fill("#agent-server-model", "scripted");
+ await page.fill("#agent-name", "scout");
+ await page.click("#agent-go");
+ await page.waitForSelector("#agent-on:not([hidden])");
+ };
+ const say = async (text) => {
+ const before = await page.$$eval("#agent-log .line.bot, #agent-log .line.tree", (els) => els.length);
+ await page.fill("#agent-text", text);
+ await page.click("#agent-say button");
+ await page.waitForFunction((n) => document.querySelectorAll("#agent-log .line.bot, #agent-log .line.tree").length > n && document.querySelector("#agent-stop").disabled, before);
+ return page.$$eval("#agent-log .line.bot, #agent-log .line.tree", (els) => els[els.length - 1].textContent);
+ };
+ await start();
+ console.log("STARTED " + await page.textContent("#agent-what"));
+ console.log("PROBED " + await say("/probe level 4"));
+ console.log("SUGGESTED " + (await page.$$eval("#agent-sugs .status", (els) => els.map((e) => e.textContent))).join(","));
+ // Accept every suggestion but one, which is dismissed (the dialog says why).
+ const ids = await page.$$eval("[data-accept]", (els) => els.map((e) => e.dataset.accept));
+ for (const id of ids.slice(0, -1)) {
+ await page.click(`[data-accept="${id}"]`);
+ await page.waitForFunction((i) => !document.querySelector(`[data-accept="${i}"]`), id);
+ }
+ await page.click(`[data-dismiss="${ids[ids.length - 1]}"]`);
+ console.log("ACCEPTED " + (await page.$$eval("#agent-sugs .sug-state", (els) => els.map((e) => e.textContent))).join(" | "));
+ console.log("ASKED " + await say("is the level low at 2?"));
+ // In a browser that is its own hub, the accepted branch is in the tree now.
+ await page.click('#tabs a[data-tab="ask"]');
+ await page.fill("#text", "level 5");
+ await page.click("#go");
+ console.log("LEVEL " + await (await page.waitForSelector("#out .card .say")).textContent());
+ await page.click('#tabs a[data-tab="agents"]');
+ await page.click("#agent-memory summary");
+ await page.click("#agent-skills-box summary");
+ await page.fill("#agent-note", "the level is out of ten");
+ await page.click("#agent-note-form button");
+ await page.fill("#skill-name", "terse");
+ await page.fill("#skill-desc", "answer in five words");
+ await page.fill("#skill-body", "Answer in five words or fewer.");
+ await page.click("#agent-skill-form button[type=submit]");
+ // Memory and skills outlive the page.
+ await start();
+ await page.click("#agent-memory summary");
+ await page.click("#agent-skills-box summary");
+ console.log("KEPT " + (await page.$$eval("#agent-notes td", (els) => els.map((e) => e.textContent))).join(" | "));
+ console.log("SKILL " + (await page.$$eval("#agent-skills code", (els) => els.map((e) => e.textContent))).join(","));
+ console.log("TERSE " + await say("/terse what is the level"));
+ console.log("DONE");
+ } catch (e) {
+ console.log("ERROR " + (e && e.stack ? e.stack.split("\n").slice(0, 3).join(" ") : e));
+ } finally {
+ await browser.close();
+ }
+})();
diff --git a/tests/conformance/make_vectors.py b/tests/conformance/make_vectors.py index dcc218e..0479f68 100644 --- a/tests/conformance/make_vectors.py +++ b/tests/conformance/make_vectors.py
@@ -30,7 +30,7 @@ sys.path.insert(0, str(ROOT / "src"))
from pyrtan import Registry, RecordedTransport, Runner # noqa: E402 from pyrtan import nlp # noqa: E402
-from pyrtan.grow import propose # noqa: E402
+from pyrtan.grow import draft_branch, propose # noqa: E402
from pyrtan.service import Service # noqa: E402 from pyrtan.tree import build as build_tree # noqa: E402
@@ -206,6 +206,19 @@ PROPOSALS = [
'say "hello" to $USER', "it's raining", ]
+# Gaps on the examples, and the branch grow.py's rules draft for each.
+DRAFTS = [
+ ("weather", {"node": "sky", "on": "code", "observed": 4}), # the hole between 3 and 45
+ ("weather", {"node": "sky", "on": "code", "observed": 100}), # past the last range
+ ("weather", {"node": "sky", "on": "code", "observed": 61}), # covered already
+ ("weather", {"node": "sky", "on": "code", "observed": None}), # missing
+ ("weather", {"node": "sky", "on": "code", "observed": "<dict>"}), # a report's type
+ ("weather", {"node": "sky", "on": "code", "observed": "Sunny Spells"}),
+ ("weather", {"node": "geocode", "on": "x", "observed": 1}), # not a decide
+ ("site-status", {"node": "status", "observed": 700}),
+ ("site-status", {"node": "status", "observed": True}),
+]
+
HUB = [ ("GET", "/capabilities", None), ("POST", "/intent", {"text": "top 3 stories on hacker news"}),
@@ -232,6 +245,19 @@ HUB = [
("POST", "/intent", {"text": "what's the temperature in the hall"}), ("GET", "/gaps", None), ("GET", "/unanswered", None),
+ ("POST", "/suggestions", {"suggestions": [
+ {"kind": "branch", "tree": "weather", "node": "sky", "on": "code", "observed": 100, "meaning": {"kind": "fail", "say": "{{ state.name }}: code 100 is not a WMO code.", "reason": "not-wmo"}, "why": "WMO codes stop at 99.", "by": {"agent": "scout", "model": "test"}, "extra": "dropped"},
+ {"kind": "trigger", "tree": "weather", "pattern": "is it raining in {place}", "covers": ["is it raining in Oslo"]},
+ {"kind": "tree", "text": "top 3 stories on lobsters", "description": "Top stories from lobste.rs.", "hosts_hint": ["lobste.rs"]},
+ {"kind": "branch", "tree": "weather", "node": "sky", "observed": 1, "meaning": {"kind": "done", "say": "{{ env.SECRET }}"}},
+ {"kind": "trigger", "tree": "weather", "pattern": "{place}", "covers": ["Oslo"]},
+ {"kind": "act", "tree": "weather"},
+ "not a table",
+ ]}),
+ ("POST", "/suggestions", {"kind": "trigger", "tree": "weather", "pattern": "is it raining in {place}", "covers": ["is it raining in Oslo", "is it raining in Bergen"]}),
+ ("POST", "/suggestions", {"nothing": True}),
+ ("GET", "/suggestions", None),
+ ("GET", "/health", None),
("GET", "/watches", None), ("GET", "/nowhere", None), ]
@@ -268,8 +294,11 @@ def _hub_exchanges(reg: Registry, fixture: Path) -> list[dict]:
def _untimed(d): """Drop when a gap or a sentence was seen: the counts and order stay."""
- for key in ("gaps", "unanswered"):
- for e in d.get(key, []) if isinstance(d, dict) else []:
+ if isinstance(d, dict) and "uptime_s" in d:
+ d.pop("uptime_s")
+ d.pop("egress", None) # a page has no egress policy to report
+ for key in ("gaps", "unanswered", "suggestions"):
+ for e in d.get(key, []) if isinstance(d, dict) and isinstance(d.get(key), list) else []:
for k in ("first_seen", "last_seen", "first", "last"): e.pop(k, None) return d
@@ -316,13 +345,19 @@ def build() -> dict:
"nlp": [_nlp_case(fn, text) for fn, text in NLP], "proposals": [{"text": text, "expect": propose(text).to_dict()} for text in PROPOSALS], "hub": _hub_exchanges(both, fixture),
+ "drafts": [{"tree": t, "gap": g, "expect": _draft(reg, t, g)} for t, g in DRAFTS],
}
+def _draft(reg: Registry, tree: str, gap: dict) -> dict | None:
+ d = draft_branch(reg.get(tree), gap)
+ return None if d is None else {k: v for k, v in d.to_dict().items() if k != "node_text"}
+
+
def main() -> int: out = ROOT / "tests" / "conformance" / "vectors.json" out.write_text(json.dumps(build(), indent=1, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")
- print(f"wrote {out}: {len(CASES)} runs, {len(READINGS)} readings, {len(NLP)} rules, {len(PROPOSALS)} proposals, {len(HUB)} requests")
+ print(f"wrote {out}: {len(CASES)} runs, {len(READINGS)} readings, {len(NLP)} rules, {len(PROPOSALS)} proposals, {len(HUB)} requests, {len(DRAFTS)} drafts")
return 0 diff --git a/tests/conformance/run.cjs b/tests/conformance/run.cjs index 674442f..9c02e9e 100644 --- a/tests/conformance/run.cjs +++ b/tests/conformance/run.cjs
@@ -37,6 +37,8 @@ function hubShape(d) {
if (d && typeof d === "object" && "status" in d && "tree" in d) return Object.assign(shape(d), { snapshot: d.snapshot === undefined ? null : d.snapshot }); if (d && Array.isArray(d.gaps)) return { gaps: d.gaps.map((g) => Object.assign({}, g, { first_seen: undefined, last_seen: undefined })) }; if (d && Array.isArray(d.unanswered)) return { unanswered: d.unanswered.map((e) => Object.assign({}, e, { first: undefined, last: undefined })) };
+ if (d && Array.isArray(d.suggestions)) return { suggestions: d.suggestions.map((e) => Object.assign({}, e, { first: undefined, last: undefined })) };
+ if (d && "uptime_s" in d) return Object.assign({}, d, { uptime_s: undefined, egress: undefined });
return d; }
@@ -65,6 +67,8 @@ function hubShape(d) {
const [status, payload] = await hub.route(x.method, x.path, x.body === null ? undefined : JSON.parse(JSON.stringify(x.body))); compare(`hub ${x.method} ${x.path} ${x.body ? JSON.stringify(x.body).slice(0, 60) : ""}`, { status: x.status, body: hubShape(x.expect) }, { status, body: hubShape(JSON.parse(JSON.stringify(payload))) }); }
+ // The branch a gap asks for, by the same rules.
+ for (const x of vectors.drafts) compare(`draft ${x.tree} ${JSON.stringify(x.gap)}`, x.expect, Pyrtan.grow.draftBranch(vectors.trees[x.tree], x.gap));
console.log(`${total - failed}/${total} agree`); process.exit(failed ? 1 : 0); })(); diff --git a/tests/conformance/vectors.json b/tests/conformance/vectors.json index 8b412b0..c71deff 100644 --- a/tests/conformance/vectors.json +++ b/tests/conformance/vectors.json
@@ -1,5 +1,155 @@
{ "clock": "2026-10-25T12:00:00+00:00",
+ "drafts": [
+ {
+ "expect": {
+ "branch": "{ when = { between = [4, 44] }, then = \"sky-4\" },",
+ "node": "sky",
+ "observed": 4,
+ "on": "code",
+ "reason": "the value falls in the hole between 3 and 45, which the ranges around it leave open",
+ "then": "sky-4",
+ "tree": "weather",
+ "when": {
+ "between": [
+ 4,
+ 44
+ ]
+ }
+ },
+ "gap": {
+ "node": "sky",
+ "observed": 4,
+ "on": "code"
+ },
+ "tree": "weather"
+ },
+ {
+ "expect": {
+ "branch": "{ when = { eq = 100 }, then = \"sky-100\" },",
+ "node": "sky",
+ "observed": 100,
+ "on": "code",
+ "reason": "the value itself",
+ "then": "sky-100",
+ "tree": "weather",
+ "when": {
+ "eq": 100
+ }
+ },
+ "gap": {
+ "node": "sky",
+ "observed": 100,
+ "on": "code"
+ },
+ "tree": "weather"
+ },
+ {
+ "expect": null,
+ "gap": {
+ "node": "sky",
+ "observed": 61,
+ "on": "code"
+ },
+ "tree": "weather"
+ },
+ {
+ "expect": {
+ "branch": "{ when = { exists = false }, then = \"sky-missing\" },",
+ "node": "sky",
+ "observed": null,
+ "on": "code",
+ "reason": "the value was missing",
+ "then": "sky-missing",
+ "tree": "weather",
+ "when": {
+ "exists": false
+ }
+ },
+ "gap": {
+ "node": "sky",
+ "observed": null,
+ "on": "code"
+ },
+ "tree": "weather"
+ },
+ {
+ "expect": {
+ "branch": "{ when = { type = \"dict\" }, then = \"sky-dict\" },",
+ "node": "sky",
+ "observed": "<dict>",
+ "on": "code",
+ "reason": "a report carries a long or structured value as its type",
+ "then": "sky-dict",
+ "tree": "weather",
+ "when": {
+ "type": "dict"
+ }
+ },
+ "gap": {
+ "node": "sky",
+ "observed": "<dict>",
+ "on": "code"
+ },
+ "tree": "weather"
+ },
+ {
+ "expect": {
+ "branch": "{ when = { eq = \"Sunny Spells\" }, then = \"sky-sunny-spells\" },",
+ "node": "sky",
+ "observed": "Sunny Spells",
+ "on": "code",
+ "reason": "the value itself",
+ "then": "sky-sunny-spells",
+ "tree": "weather",
+ "when": {
+ "eq": "Sunny Spells"
+ }
+ },
+ "gap": {
+ "node": "sky",
+ "observed": "Sunny Spells",
+ "on": "code"
+ },
+ "tree": "weather"
+ },
+ {
+ "expect": null,
+ "gap": {
+ "node": "geocode",
+ "observed": 1,
+ "on": "x"
+ },
+ "tree": "weather"
+ },
+ {
+ "expect": null,
+ "gap": {
+ "node": "status",
+ "observed": 700
+ },
+ "tree": "site-status"
+ },
+ {
+ "expect": {
+ "branch": "{ when = { eq = true }, then = \"status-true\" },",
+ "node": "status",
+ "observed": true,
+ "on": "resp.status",
+ "reason": "the value itself",
+ "then": "status-true",
+ "tree": "site-status",
+ "when": {
+ "eq": true
+ }
+ },
+ "gap": {
+ "node": "status",
+ "observed": true
+ },
+ "tree": "site-status"
+ }
+ ],
"fixture": "tests/fixtures/examples.json", "hub": [ {
@@ -3122,6 +3272,224 @@
"path": "/unanswered", "status": 200 },
+ {
+ "body": {
+ "suggestions": [
+ {
+ "by": {
+ "agent": "scout",
+ "model": "test"
+ },
+ "extra": "dropped",
+ "kind": "branch",
+ "meaning": {
+ "kind": "fail",
+ "reason": "not-wmo",
+ "say": "{{ state.name }}: code 100 is not a WMO code."
+ },
+ "node": "sky",
+ "observed": 100,
+ "on": "code",
+ "tree": "weather",
+ "why": "WMO codes stop at 99."
+ },
+ {
+ "covers": [
+ "is it raining in Oslo"
+ ],
+ "kind": "trigger",
+ "pattern": "is it raining in {place}",
+ "tree": "weather"
+ },
+ {
+ "description": "Top stories from lobste.rs.",
+ "hosts_hint": [
+ "lobste.rs"
+ ],
+ "kind": "tree",
+ "text": "top 3 stories on lobsters"
+ },
+ {
+ "kind": "branch",
+ "meaning": {
+ "kind": "done",
+ "say": "{{ env.SECRET }}"
+ },
+ "node": "sky",
+ "observed": 1,
+ "tree": "weather"
+ },
+ {
+ "covers": [
+ "Oslo"
+ ],
+ "kind": "trigger",
+ "pattern": "{place}",
+ "tree": "weather"
+ },
+ {
+ "kind": "act",
+ "tree": "weather"
+ },
+ "not a table"
+ ]
+ },
+ "expect": {
+ "kept": 3,
+ "refused": [
+ {
+ "index": 3,
+ "why": "say may read inputs, state, tree, not env"
+ },
+ {
+ "index": 4,
+ "why": "pattern: trigger '{place}' has no literal words; it would match anything"
+ },
+ {
+ "index": 5,
+ "why": "kind is one of branch, trigger, tree"
+ },
+ {
+ "index": 6,
+ "why": "a suggestion is a table"
+ }
+ ],
+ "status": "ok"
+ },
+ "method": "POST",
+ "path": "/suggestions",
+ "status": 200
+ },
+ {
+ "body": {
+ "covers": [
+ "is it raining in Oslo",
+ "is it raining in Bergen"
+ ],
+ "kind": "trigger",
+ "pattern": "is it raining in {place}",
+ "tree": "weather"
+ },
+ "expect": {
+ "kept": 1,
+ "refused": [],
+ "status": "ok"
+ },
+ "method": "POST",
+ "path": "/suggestions",
+ "status": 200
+ },
+ {
+ "body": {
+ "nothing": true
+ },
+ "expect": {
+ "reason": "bad-request",
+ "say": "Send {\"suggestions\": [{\"kind\": \"branch\"|\"trigger\"|\"tree\", ...}]}.",
+ "status": "error"
+ },
+ "method": "POST",
+ "path": "/suggestions",
+ "status": 400
+ },
+ {
+ "body": null,
+ "expect": {
+ "suggestions": [
+ {
+ "count": 2,
+ "covers": [
+ "is it raining in Oslo",
+ "is it raining in Bergen"
+ ],
+ "kind": "trigger",
+ "pattern": "is it raining in {place}",
+ "tree": "weather"
+ },
+ {
+ "by": {
+ "agent": "scout",
+ "model": "test"
+ },
+ "count": 1,
+ "kind": "branch",
+ "meaning": {
+ "kind": "fail",
+ "reason": "not-wmo",
+ "say": "{{ state.name }}: code 100 is not a WMO code."
+ },
+ "node": "sky",
+ "observed": 100,
+ "on": "code",
+ "tree": "weather",
+ "why": "WMO codes stop at 99."
+ },
+ {
+ "count": 1,
+ "description": "Top stories from lobste.rs.",
+ "hosts_hint": [
+ "lobste.rs"
+ ],
+ "kind": "tree",
+ "text": "top 3 stories on lobsters"
+ }
+ ]
+ },
+ "method": "GET",
+ "path": "/suggestions",
+ "status": 200
+ },
+ {
+ "body": null,
+ "expect": {
+ "errors": {},
+ "gaps": 1,
+ "name": "this browser",
+ "runs": 19,
+ "status": "ok",
+ "suggestions": 3,
+ "trees": [
+ "air-quality",
+ "books",
+ "count",
+ "dad-joke",
+ "define",
+ "disk-space",
+ "earthquakes",
+ "exchange-rate",
+ "fixed",
+ "github-release",
+ "hacker-news",
+ "hacker-news-item",
+ "hn-search",
+ "holidays",
+ "iss",
+ "library",
+ "log-errors",
+ "modem-check",
+ "npm-package",
+ "order",
+ "ping",
+ "pypi-package",
+ "remind",
+ "rendered-headline",
+ "room-temperature",
+ "site-status",
+ "small-talk",
+ "sun",
+ "synonyms",
+ "tie-a",
+ "tie-b",
+ "weather",
+ "wikipedia"
+ ],
+ "unanswered": 1,
+ "watches": 0
+ },
+ "method": "GET",
+ "path": "/health",
+ "status": 200
+ },
{ "body": null, "expect": { diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..5cb420b --- /dev/null +++ b/tests/test_agents.py
@@ -0,0 +1,330 @@
+"""Agents beside the swarm: what they may suggest, where it is kept, and how
+it becomes a tree (suggestions.py, agents.js, the console's Agents tab).
+
+Nothing here runs a model. The agent's side is tested with a scripted one
+(tests/agents.cjs) and, in a browser, with a scripted server that speaks
+the OpenAI chat API, the way Ollama or llama.cpp would.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import subprocess
+import threading
+from http.server import BaseHTTPRequestHandler, SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+import pytest
+
+from conftest import ROOT
+from pyrtan import suggestions as sg
+from pyrtan.cli import main
+from pyrtan.grants import ability_for
+from pyrtan.hub import serve_hub
+from pyrtan.registry import Registry
+from pyrtan.service import Service
+from pyrtan.tree import TreeError, load_tree
+
+LEVEL = '''name = "level"
+version = 1
+namespace = "test"
+license = "MIT"
+description = "Whether a level from 0 to 10 is low or high."
+hosts = []
+start = "which"
+
+[[triggers]]
+pattern = "level {n:int}"
+[[triggers]]
+pattern = "is the level low at {n:int}"
+
+[inputs.n]
+type = "int"
+description = "a level, 0 to 10"
+
+[nodes.which]
+kind = "decide"
+on = "inputs.n"
+branches = [
+ { when = { between = [0, 3] }, then = "low" },
+ { when = { between = [7, 10] }, then = "high" },
+]
+
+[nodes.low]
+kind = "done"
+say = "Level {{ inputs.n }} is low."
+
+[nodes.high]
+kind = "done"
+say = "Level {{ inputs.n }} is high."
+'''
+
+BY = {"agent": "scout", "model": "test-model", "engine": "scripted"}
+BRANCH = {"kind": "branch", "tree": "level", "node": "which", "on": "inputs.n", "observed": 5, "meaning": {"kind": "done", "say": "Level {{ inputs.n }} is in the middle.", "reason": "middle"}, "why": "between low and high", "by": BY}
+TRIGGER = {"kind": "trigger", "tree": "level", "pattern": "set the level to {n:int}", "covers": ["set the level to 5"], "by": BY}
+NEW_TREE = {"kind": "tree", "text": "top 3 stories on lobsters", "description": "Top stories from lobste.rs.", "hosts_hint": ["lobste.rs"], "by": BY}
+
+
+@pytest.fixture
+def trees(tmp_path):
+ d = tmp_path / "trees"
+ d.mkdir()
+ (d / "level.toml").write_text(LEVEL)
+ return d
+
+
+# ---------------------------------------------------------------- what may be suggested
+
+
+@pytest.mark.parametrize("bad, why", [
+ ("nope", "a table"),
+ ({"kind": "observe"}, "kind is one of"),
+ ({**BRANCH, "meaning": {"kind": "observe", "say": "x"}}, "a done or a fail"),
+ ({**BRANCH, "meaning": {"kind": "done", "say": "{{ env.TOKEN }}"}}, "not env"),
+ ({**BRANCH, "meaning": {"kind": "done", "say": "x" * 301}}, "at most 300"),
+ ({**BRANCH, "meaning": {"kind": "fail", "say": "x", "reason": "Not A Slug"}}, "slug"),
+ ({k: v for k, v in BRANCH.items() if k != "observed"}, "observed"),
+ ({**BRANCH, "tree": ""}, "names its tree"),
+ ({**TRIGGER, "pattern": "{n}"}, "no literal words"),
+ ({**TRIGGER, "covers": []}, "covers")

The diff is cut here; the rest is in the clone.