KeiSeiKit-1.0/_primitives/_rust/kei-agent-runtime/src/dna.rs
Parfii-bot 0ea429054f feat(wave14): 5 bio-inspired primitives + phase2 cleanup + substrate dogfood
## Wave 14 — 5 new primitives (44 crates total, 713 tests green)

All specs written as task.toml → passed through kei-agent-runtime prepare
→ composed prompts via capability fragments → Agent tool invocation.
First fully-dogfooded wave.

- kei-prune (9 tests): biological pruning. `candidates(idle_days)` +
  `mark_retired(id)` on sidecar `prune_retirements` table (agents.status
  CHECK precluded 'retired' value).
- kei-discover (8 tests): federated marketplace discovery stub. UNIQUE
  slug via custom migration + FTS5 on slug+description. Engine-native
  via kei-entity-store. Typed DuplicateSlug error.
- kei-brain-view (6-8 tests): stdout visualizer for ledger taxonomy
  graph + agent lineage. Tree / stats / lineage subcommands. NO_COLOR
  env respected. No kei-entity-store dep (direct rusqlite).
- kei-hibernate (6 tests): whole-brain tar.zst export/import. Manifest
  with sha256 per-file, version gate, safe_join on extract, dry-run
  mode. tar 0.4 + zstd 0.13.
- kei-ledger-sign (7 tests): ed25519 creator attestation. keygen / sign /
  verify CLI. Canonical message `dna|spec_sha|creator_id` with pipe
  rejection. chmod 600 on key storage (unix). Tamper-detection on load
  via pubkey re-derivation.

## Phase 2 cleanup shipped in same commit

- LOC splits: walk.rs 221→91 (path_safety.rs + wikilink.rs extracted),
  prepare.rs 228→199 (dead build_ledger_row removed, fn helpers split).
- Clippy pass: 6 warnings fixed (derivable_impls, manual_contains,
  type_complexity x2, doc_overindented_list_items x2) in
  kei-entity-store, kei-ledger, kei-spawn.
- DNA eprintln removed from kei-agent-runtime/src/dna.rs (stderr
  pollution from library parse).
- kei-pipe integrations: hot_reload.rs (kei-watch wrapper, sync API,
  50ms debounce) + scheduler_bridge.rs (kei-scheduler executor, shell
  exec documented). +6 tests.
- Workspace [workspace.dependencies] centralised: rusqlite/chrono/
  anyhow/thiserror/tempfile/toml — future crates opt in via
  `.workspace = true`. Existing pins preserved.

## Substrate dogfood verified

task.toml → `kei-agent-runtime prepare` → DNA + composed prompt from
capability fragments → Agent tool invocation. kei-spawn also tested
end-to-end (prompt.md written to tasks/<agent-id>/, ledger row created).

Verified: cargo check --workspace clean, 713 tests passing,
substrate_integration.sh ✓, hook_wiring_integration.sh ✓.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:19:25 +08:00

170 lines
5.6 KiB
Rust

//! Layer G — DNA identity for agent invocations.
//!
//! DNA format: `<role>::<caps-bitmap>::<scope-hash>::<body-hash>-<nonce>`
//! where
//! - `role` — role slug, e.g. `edit-local`
//! - `caps-bitmap` — hyphen-separated 2-char atom codes (ordered, from
//! the resolved capability list)
//! - `scope-hash` — 8-char truncated SHA-256 of canonicalised scope fields
//! (32-bit; widened from 16-bit to push birthday collision
//! threshold from ~256 to ~65k agents per role+caps group)
//! - `body-hash` — 8-char truncated SHA-256 of `task.body.text` (32-bit)
//! - `nonce` — 8-char hex from `rand::random::<u32>()` (full 32-bit
//! entropy; was 16-bit pre-2026-04 H4/M4/S3 widening)
//!
//! Constructor Pattern: one cube = DNA identity primitive only. No I/O.
//!
//! Round-trip: `compose` → `render` → `parse` → equal.
//! Parse accepts both shipped DNA strings and hand-written ones; it enforces
//! the 5-segment shape but tolerates arbitrary (non-empty) segment content
//! so future schema extensions don't break old ledger rows. For rolling
//! upgrade, 4-hex legacy hash/nonce values still parse silently — the
//! fallback is a successful parse path, not an error.
use crate::capability::TaskSpec;
use crate::role::ResolvedRole;
use sha2::{Digest, Sha256};
use thiserror::Error;
/// Capability-name → 2-char atom code lookup.
///
/// Stable, extensible — additions allowed; removals NOT. `compose` emits
/// `?\?` for unknown names so missing entries are visibly flagged rather
/// than silently dropped.
pub const CAP_CODES: &[(&str, &str)] = &[
("policy::no-git-ops", "NG"),
("scope::files-whitelist", "FW"),
("scope::files-denylist", "FD"),
("quality::constructor-pattern", "CP"),
("quality::cargo-check-green", "CG"),
("quality::tests-green", "TG"),
("safety::no-dep-bump", "ND"),
("output::report-format", "RF"),
("output::severity-grade", "SG"),
("tools::deny-tools", "DT"),
("tools::bash-allowlist", "BA"),
];
/// Agent DNA — composition fingerprint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dna {
pub role: String,
pub caps_bitmap: String,
pub scope_hash: String,
pub body_hash: String,
pub nonce: String,
}
/// Error during DNA parsing.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum DnaError {
#[error("DNA string must have 4 `::` segments and `<body>-<nonce>` tail")]
Shape,
#[error("DNA segment `{0}` is empty")]
EmptySegment(&'static str),
}
impl Dna {
/// Build DNA from a task + already-resolved role.
pub fn compose(task: &TaskSpec, resolved: &ResolvedRole) -> Self {
let caps_bitmap = build_caps_bitmap(&resolved.required);
let scope_hash = short_sha256(&canonical_scope(task));
let body_hash = short_sha256(&task.body.text);
let nonce = nonce_hex();
Self {
role: task.task.role.clone(),
caps_bitmap,
scope_hash,
body_hash,
nonce,
}
}
/// Render to the canonical wire format.
pub fn render(&self) -> String {
format!(
"{}::{}::{}::{}-{}",
self.role, self.caps_bitmap, self.scope_hash, self.body_hash, self.nonce
)
}
/// Parse a DNA string. Lenient on segment content, strict on shape.
/// Accepts both 8-hex (current) and 4-hex (legacy pre-widening) values
/// for `scope_hash`, `body_hash`, `nonce` — both widths parse silently.
pub fn parse(s: &str) -> Result<Self, DnaError> {
let parts: Vec<&str> = s.splitn(4, "::").collect();
if parts.len() != 4 {
return Err(DnaError::Shape);
}
let (role, caps_bitmap, scope_hash) = (parts[0], parts[1], parts[2]);
let (body_hash, nonce) = parts[3].rsplit_once('-').ok_or(DnaError::Shape)?;
ensure_non_empty(role, caps_bitmap, scope_hash, body_hash, nonce)?;
Ok(Self {
role: role.into(),
caps_bitmap: caps_bitmap.into(),
scope_hash: scope_hash.into(),
body_hash: body_hash.into(),
nonce: nonce.into(),
})
}
}
fn ensure_non_empty(
role: &str,
caps_bitmap: &str,
scope_hash: &str,
body_hash: &str,
nonce: &str,
) -> Result<(), DnaError> {
for (name, value) in [
("role", role),
("caps_bitmap", caps_bitmap),
("scope_hash", scope_hash),
("body_hash", body_hash),
("nonce", nonce),
] {
if value.is_empty() {
return Err(DnaError::EmptySegment(name));
}
}
Ok(())
}
fn build_caps_bitmap(caps: &[String]) -> String {
caps.iter()
.map(|c| code_for(c).to_string())
.collect::<Vec<_>>()
.join("-")
}
fn code_for(cap_name: &str) -> &'static str {
CAP_CODES
.iter()
.find(|(n, _)| *n == cap_name)
.map(|(_, c)| *c)
.unwrap_or("??")
}
fn canonical_scope(task: &TaskSpec) -> String {
let mut wl = task.scope.files_whitelist.clone();
wl.sort();
let mut dl = task.scope.files_denylist.clone();
dl.sort();
format!("wl={}\ndl={}", wl.join(","), dl.join(","))
}
fn short_sha256(input: &str) -> String {
let digest = Sha256::digest(input.as_bytes());
// 4 bytes = 8 hex chars = 32-bit truncation (widened from 16-bit).
format!(
"{:02X}{:02X}{:02X}{:02X}",
digest[0], digest[1], digest[2], digest[3]
)
}
fn nonce_hex() -> String {
// 32-bit nonce (widened from 16-bit). Birthday collision threshold
// ~65k DNAs sharing the same role+caps+scope+body triple.
let r: u32 = rand::random();
format!("{r:08x}")
}