agent

The demo agent: a model writes a spec from the block manifests, and code checks it before anyone sees it.

author(concept, ask) asks a model for one demo on a concept. The model writes data only: a JSON spec. It never writes code. Then the loop checks the spec, and gives the errors back to the model to fix:

  1. Parse the reply as JSON.
  2. validate the spec: types, binds, gates and the think rule.
  3. check the spec in headless Chrome: it draws, it plays, the keys work, the contrast is good.
  4. On errors, ask for a repair with only these errors. At most two repairs.
  5. Give up quietly: return None and log the errors. The caller keeps its text lesson.

ask(prompt, sp) is any function that returns text, as in abhyasa. The tests use a fake one.

The catalog

The prompt lists the blocks and fns from their manifests, so it never names a block that the runtime does not have.


source

catalog

def catalog()->str:

The blocks and fns, with their purpose and props, as text for the prompt.

The catalog names every block and every fn.

cat = catalog()
for n in [*manifests(), *fn_manifests()]: assert f'- {n}: ' in cat, n
print(cat[:600])

The system prompt

The system prompt gives the rules of a good demo, the shape of a spec, and the catalog.


source

demo_sp

def demo_sp()->str:

The system prompt of the demo agent.

assert 'You never write code' in demo_sp() and '- wheel: ' in demo_sp()

The user prompt

The user prompt describes the concept, the first error of the learner (if there is one), and two gallery demos as examples. near picks the gallery demos that share the most words with the concept. A long table in an example is cut to three rows.


source

near

def near(
    concept, k:int=2
)->list:

The k gallery demos that share the most words with concept.


source

demo_prompt

def demo_prompt(
    concept, first_error:str='', examples:tuple=()
)->str:

The prompt that asks for a demo on concept, a dict with name, statement, subject and misconceptions.

c = dict(name='Reciprocal rank fusion', statement='Sum 1/(k + rank) over the lists.', subject='ml', misconceptions=['raw scores add'])
p = demo_prompt(c, 'You add the raw scores.', near(c))
assert 'first error: You add the raw scores.' in p and '"id": "reciprocal-rank-fusion"' in p

The repair prompt

The repair prompt gives back the spec and only its errors.


source

repair_prompt

def repair_prompt(
    spec, errors
)->str:

The prompt that asks the model to fix only errors in spec.

Parse

parse_json finds the JSON object in a reply, also inside a fence or after some text.


source

parse_json

def parse_json(
    s:str
)->dict:

The first JSON object in s. Raise ValueError when there is none.

test_eq(parse_json('Here:\n```json\n{"a": 1}\n```'), {'a': 1})
test_fail(lambda: parse_json('no json'), contains='no JSON')

author

author runs the loop. It stamps the spec with the schema, an id and the concept slug. log, when given, gets one entry for each failed try.


source

author

def author(
    concept, ask, first_error:str='', examples:NoneType=None, check:bool=True, repairs:int=2, log:list=None
)->dict | None:

A checked demo spec on concept from the model ask, or None after repairs failed repairs.

Tests with a fake model

fake replies from a list, one reply for each call, and keeps the prompts.

from rupaka.spec import examples as block_examples
def fake(*replies):
    calls = []
    def ask(p, sp=''): calls.append(p); return replies[min(len(calls), len(replies)) - 1]
    ask.calls = calls
    return ask
good = json.dumps(block_examples()['gate'])

A good spec passes on the first call. The spec gets the concept slug.

a = fake(good)
s = author(dict(name='Sums', slug='sums'), a, check=False)
test_eq((s['concept'], len(a.calls)), ('sums', 1))

A spec with one error gets one repair. The repair prompt names the error.

broken = json.loads(good); del broken['blocks'][1]['after']
a, log = fake(json.dumps(broken), good), []
s = author('Sums', a, check=False, log=log)
assert s is not None and len(a.calls) == 2
assert 'gives away the answer of gate "sum"' in a.calls[1] and 'Fix only these errors' in a.calls[1]
test_eq(log[0]['tries'], 1)

Three bad replies give up quietly: the result is None, and the log has three entries.

log = []
test_eq(author('Sums', fake('not json', json.dumps(broken), '{}'), check=False, log=log), None)
test_eq(len(log), 3)

A model that fails gives up too, with no exception.

def down(p, sp=''): raise ConnectionError('no network')
test_eq(author('Sums', down, check=False), None)

With a browser, check runs too, and a good spec still passes in one call.

from rupaka.check import can_check
if can_check(): assert author('Sums', fake(good)) is not None

A live run

A live run uses a real model, so it is not part of the tests. With the rishi package and its model claude/sonnet, it looks like this:

from rishi import Chat, resp_text
def ask(p, sp=''):
    chat = Chat('claude/sonnet', sp=sp)
    try: return resp_text(chat(p))
    finally: chat.close()
log = []
spec = author(dict(name='Guṇa sandhi', statement='a or ā before i, u or ṛ gives e, o or ar.', subject='sanskrit'), ask, log=log)

The first live run (26 October 2026, claude/sonnet, three concepts: guṇa sandhi, cosine similarity and binary search):

concept result model calls
guṇa sandhi passed on the first try 1
cosine similarity passed after one repair: a point showed the answer before the gate, and the think rule caught it 2
binary search passed on the first try 1

So 3 of 3 passed, 2 of them on the first try. One spec put {n} in its plain-text ask. The check could not see it, so validate now refuses a template in a plain-text prop.