diff --git a/README.md b/README.md index 1ce2adc..c436b82 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Powered by the [Genome Evolution Protocol](https://evomap.ai) and | Seam | dsh event | Behaviour | | --- | --- | --- | -| Asset priming | `agent/pre-step`, once per turn | Searches the Proxy with that turn's own prompt — only the text the person typed — fetches the two leading matches that clear the similarity line and have a strategy, in one call, and injects the strategy of the first that actually came back. Ids the Hub declined to deliver are remembered for a day and skipped, so a slot is not spent twice on one. A lookup slower than the wait budget is injected into the next step instead of holding the current one. Assets already injected in this session are skipped. | +| Asset priming | `agent/pre-step`, once per turn | Searches the Proxy with that turn's own prompt — only the text the person typed — fetches the two leading matches that clear the similarity line and have a strategy, in one call, and injects the strategy of the first that actually came back, announced by the asset's short title rather than its hash. The hash appears once, on the line that asks for the outcome, because that call needs it. Ids the Hub declined to deliver are remembered for a day and skipped, so a slot is not spent twice on one. A lookup slower than the wait budget is injected into the next step instead of holding the current one. Assets already injected in this session are skipped. | | Signal detection | successful `tools/result` for `write`, `edit`, or `str_replace_editor` | Tags the turn with improvement signals and injects one bounded notice per file/signal set. | | Reuse feedback | `session/event` → `turn/end` | Reports each strategy injected during that turn to the Proxy with the turn's outcome, marked as automatic and unconfirmed. Injected asset ids are written to `~/.evolver/state/` as they are injected and read back at report time, so a report survives the process dying in between. Reports are keyed on the asset id alone, and each verdict is written twice: to the Proxy, and as a `value.reuse_hit` / `value.reuse_outcome` root event in `~/.evomap/evolution/root_events.jsonl`. The local write is the one that closes the loop — the actuator that re-orders future candidates reads root events, not the Hub. An asset the model reported itself with `evolver_asset_reuse_result` is left alone. | | Verdict correction | `agent/pre-step` | When the prompt that opens a later turn in the same session plainly says the last answer did not hold, the verdicts already sent for that session are revised to `failed` — each asset at most once, since the Hub counts every report it receives. | diff --git a/src/prime.js b/src/prime.js index a6af0d5..f6958a3 100644 --- a/src/prime.js +++ b/src/prime.js @@ -13,6 +13,7 @@ const FETCH_LIMIT = 2; const MIN_PROMPT_CHARS = 8; const PROMPT_MAX_CHARS = 400; const STEP_MAX_CHARS = 400; +const TITLE_MAX_CHARS = 80; const DEFAULT_MIN_SIMILARITY = 0.3; const UNSCORED = -1; const EMPTY_MATCH = { ids: [], text: '' }; @@ -49,6 +50,13 @@ function fetchedById(data) { return new Map(found.filter((asset) => asset?.asset_id).map((asset) => [asset.asset_id, asset])); } +function readableNameOf(hit, asset) { + for (const candidate of [hit?.short_title, asset?.short_title]) { + if (typeof candidate === 'string' && candidate.trim()) return candidate.trim().slice(0, TITLE_MAX_CHARS); + } + return hit?.asset_type ?? 'Gene'; +} + function similarityOf(hit) { return typeof hit.similarity === 'number' ? hit.similarity : UNSCORED; } @@ -115,10 +123,10 @@ export async function hubGene(proxyFetch, text, { signal, listedIds = new Set(), return { ids: [candidate.asset_id], text: [ - `[Evolution Memory] Strategy reused from ${candidate.asset_type ?? 'Gene'} ${candidate.asset_id} (${source}):`, + `[Evolution Memory] ${readableNameOf(candidate, byId.get(candidate.asset_id))} (${source}):`, ...steps.map((step, index) => `${index + 1}. ${step}`), '', - 'Apply it where it fits, then report the outcome with evolver_asset_reuse_result.', + `Apply it where it fits, then report the outcome with evolver_asset_reuse_result for ${candidate.asset_id}.`, ].join('\n'), }; } diff --git a/test/apply.test.js b/test/apply.test.js index c826ea5..ff5d0ea 100644 --- a/test/apply.test.js +++ b/test/apply.test.js @@ -57,6 +57,15 @@ async function primedBy(listeners, agent, prompt = 'add a retry to the uploader' return decision.messages.slice(1); } +async function untilCount(items, predicate, count, deadlineMs = 2_000) { + const start = Date.now(); + while (items.filter(predicate).length < count) { + if (Date.now() - start > deadlineMs) throw new Error(`only ${items.filter(predicate).length} of ${count} expected entries arrived`); + await new Promise((resolve) => { setTimeout(resolve, 10); }); + } + return items.filter(predicate); +} + async function untilRequest(requests, path, deadlineMs = 2_000) { const start = Date.now(); while (!requests.some((request) => request.path === path)) { @@ -205,7 +214,7 @@ test('every turn looks the Hub up with its own prompt, without repeating assets' ); assert.equal(requests[0].body.text, 'add a retry to the uploader'); assert.equal(requests[2].body.text, 'now make the download resumable'); - assert.match(first.at(-1).content[0].text, /Strategy reused from Gene sha256:abc/); + assert.match(first.at(-1).content[0].text, /evolver_asset_reuse_result for sha256:abc\./); assert.match(first.at(-1).content[0].text, /^1\. Retry with backoff\.$/m); assert.deepEqual(sameTurn, []); assert.equal(second.length, 1); @@ -266,7 +275,7 @@ test('a search slower than the wait budget injects itself instead of holding the assert.deepEqual(primed, [], 'a step that waited for the slow search would carry its strategy'); await untilInjected(injected, 1); - assert.match(injected[0].content[0].text, /Strategy reused from Gene sha256:slow/); + assert.match(injected[0].content[0].text, /evolver_asset_reuse_result for sha256:slow\./); assert.match(injected[0].content[0].text, /^1\. Arrived late\.$/m); } finally { process.env.HOME = home; @@ -370,7 +379,7 @@ test('an injected strategy is reported back when the turn ends', async () => { apply(ctx, Config({ projectDir, proxyPort: server.address().port })); const { agent } = fakeAgent({ sessionId: 'session-reuse', cwd: projectDir }); const primed = await primedBy(listeners, agent); - assert.match(primed.at(-1).content[0].text, /Strategy reused from Gene sha256:used/); + assert.match(primed.at(-1).content[0].text, /evolver_asset_reuse_result for sha256:used\./); listeners.get('session/event')(agent.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }); await untilRequest(requests, '/asset/reuse-result'); @@ -497,7 +506,7 @@ test('a strategy that arrived after its own turn ended is still reported, under listeners.get('session/event')(agent.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }); await untilInjected(injected, 1); - assert.match(injected[0].content[0].text, /Strategy reused from Gene sha256:late/); + assert.match(injected[0].content[0].text, /evolver_asset_reuse_result for sha256:late\./); assert.deepEqual(requests.filter((request) => request.path === '/asset/reuse-result'), []); listeners.get('session/event')(agent.session, { type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } }); @@ -583,20 +592,18 @@ test('a prompt that says the answer did not hold revises the verdict once, and o assert.equal(reuseResults()[0].body.outcome, 'success'); await primedBy(listeners, agent, '还是不行,报一样的错', 2, 1); - await untilRequest(requests.filter((r) => r.body?.outcome === 'failed'), '/asset/reuse-result') - .catch(() => {}); - await untilInjected([], 0).catch(() => {}); - await new Promise((resolve) => setTimeout(resolve, 150)); + const isFailedReport = (request) => request.path === '/asset/reuse-result' && request.body?.outcome === 'failed'; + await untilCount(requests, isFailedReport, 1); - const failed = reuseResults().filter((request) => request.body.outcome === 'failed'); + const failed = requests.filter(isFailedReport); assert.equal(failed.length, 1, 'the correction is sent exactly once'); assert.equal(failed[0].body.asset_id, 'sha256:kept'); assert.match(failed[0].body.reason, /read as a correction/); await primedBy(listeners, agent, '还是不行啊', 3, 1); - await new Promise((resolve) => setTimeout(resolve, 150)); + await new Promise((resolve) => setTimeout(resolve, 300)); assert.equal( - reuseResults().filter((request) => request.body.outcome === 'failed').length, + requests.filter(isFailedReport).length, 1, 'a second complaint does not send a second negative', ); diff --git a/test/prime.test.js b/test/prime.test.js index ae79ccd..f3f0153 100644 --- a/test/prime.test.js +++ b/test/prime.test.js @@ -45,7 +45,8 @@ test('one asset is fetched and injected as its strategy alone', async () => { assert.deepEqual(calls[1].body, { asset_ids: ['sha256:abc', 'sha256:second'] }); assert.equal(calls[0].signal, controller.signal); assert.deepEqual(ids, ['sha256:abc']); - assert.match(text, /Strategy reused from Gene sha256:abc \(EvoMap network\)/); + assert.match(text, /\[Evolution Memory\] Gene \(EvoMap network\)/); + assert.match(text, /evolver_asset_reuse_result for sha256:abc\./); assert.match(text, /^1\. Measure the failure rate first\.$/m); assert.match(text, /^2\. Add jittered backoff\.$/m); assert.doesNotMatch(text, /Retry the upload with backoff|npm test|sha256:second/); @@ -86,7 +87,8 @@ test('an asset already injected this session is passed over', async () => { assert.deepEqual(calls[1].body, { asset_ids: ['sha256:next'] }); assert.ok(!JSON.stringify(calls[1].body).includes('sha256:abc')); assert.deepEqual(ids, ['sha256:next']); - assert.match(text, /Capsule sha256:next/); + assert.match(text, /\[Evolution Memory\] Capsule \(EvoMap network\)/); + assert.match(text, /evolver_asset_reuse_result for sha256:next\./); assert.match(text, /^1\. Drain the queue first\.$/m); }); @@ -244,3 +246,35 @@ test('an id the Hub would not deliver is reported, and skipped when asked to ski assert.deepEqual(calls.at(-1).body.asset_ids, ['sha256:real'], 'the wasted slot is not spent again'); assert.deepEqual(second.ids, ['sha256:real']); }); + +test('a named gene is announced by its name, and its hash only where it gets used', async () => { + const { proxyFetch } = stubProxy({ + search: { + results: [{ + asset_id: 'sha256:named', asset_type: 'Gene', has_strategy: true, similarity: 0.9, + short_title: 'Chinese Social Media Writing Template', + nl_summary: 'A long explanation that has no business being in front of the model on every turn.'.repeat(3), + }], + }, + fetch: { assets: [{ asset_id: 'sha256:named', summary: 'Also long, also not the header.', strategy: ['Draft it.'] }] }, + }); + + const { text } = await hubGene(proxyFetch, 'add a retry to the uploader'); + + assert.match(text, /^\[Evolution Memory\] Chinese Social Media Writing Template \(EvoMap network\):$/m); + assert.doesNotMatch(text.split('\n')[0], /sha256:/, 'the hash is not what the header is for'); + assert.match(text, /evolver_asset_reuse_result for sha256:named\./); + assert.doesNotMatch(text, /no business being in front of the model|Also long/); +}); + +test('an unnamed asset falls back to its type rather than reciting a summary', async () => { + const { proxyFetch } = stubProxy({ + search: { results: [{ asset_id: 'sha256:plain', asset_type: 'Capsule', has_strategy: true, similarity: 0.9 }] }, + fetch: { assets: [{ asset_id: 'sha256:plain', summary: 'A summary that must stay out of the prompt.', strategy: ['Do it.'] }] }, + }); + + const { text } = await hubGene(proxyFetch, 'add a retry to the uploader'); + + assert.match(text, /^\[Evolution Memory\] Capsule \(EvoMap network\):$/m); + assert.doesNotMatch(text, /must stay out of the prompt/); +});