Smithy
indusagi::smithyis the agent-builder meta-tool of the Rust edition — a config-driven and interactive "forge" that interviews a user, merges the answers over a starter profile into a validatedAgentBlueprint, and instantiates it as a runnable runtimeAgent. It is apub modinside the mergedindusagicrate, re-exported fromcrates/indusagi/src/smithy/mod.rs. Instead of shipping a family of near-identicalcreate_*_agentfactories, Smithy ships one data-driven generator: starter profiles supply partial blueprints,define_agentmerges a spec over a profile and validates it, andto_agent_configbridges the finished blueprint onto the runtime'sAgentConfigplus a realToolBox. TheForgebuild session wires the five internal layers — config, persona, knowledge, runtime ledger, ui transcript — into one drivable interview behind a terminal-freeForgeViewseam, so the whole flow is scriptable and testable with no real I/O.
Table of Contents
- Module layout
- Public exports
- Blueprints and tool collections
- Validation and serialization
- Starter profiles
- The merge generator
- The runtime bridge
- The Forge build session
- CLI flags
- The knowledge pack
- The tool ledger
- The transcript projector and view seam
- Relationship to neighbors
Module layout
Smithy mirrors the clean-room TypeScript smithy/ subsystem layer-for-layer. The
tree under crates/indusagi/src/smithy/ is six modules plus the forge conductor:
| Path | Holds |
|---|---|
smithy/config/flag_reader.rs |
The FlagReader finite-state machine, SmithyConfig, FlagSpec, FLAG_TABLE; reads Smithy's own CLI argv slice |
smithy/persona/blueprint.rs |
AgentBlueprint shape + hand-rolled validation, the ToolCollectionName enum, and the (de)serialization helpers |
smithy/persona/profiles.rs |
The PROFILES starter table (coder/researcher/reviewer) and its lookup helpers |
smithy/persona/define_agent.rs |
The single define_agent merge generator and the to_agent_config runtime bridge |
smithy/knowledge/loader.rs |
load_knowledge over the include_dir!-embedded manifest.json + guides/*.md |
smithy/runtime/tool_ledger.rs |
The ToolLedger append-only account of tool executions and its summary types |
smithy/ui/transcript.rs |
The TranscriptModel visitor, the sanitizers, and the ForgeView/ConsoleForgeView text seam |
smithy/forge.rs |
The Forge build session conducting all of the above |
Each mod.rs (config, persona, knowledge, runtime, ui) re-exports its
leaf module's public surface; smithy/mod.rs flattens all of them so a consumer
writes use indusagi::smithy::{Forge, define_agent, …}.
Public exports
Almost everything below is flattened into smithy/mod.rs — the single import site
for the subsystem, so a consumer writes use indusagi::smithy::{Forge, …}. The one
exception is FLAG_TABLE, which smithy/mod.rs does not re-export; it is public
from the config submodule only (use indusagi::smithy::config::FLAG_TABLE).
config — the flag FSM
| Name | Kind | Source | Purpose |
|---|---|---|---|
FlagReader |
struct | config/flag_reader.rs |
An explicit 3-state (ExpectFlag/ExpectValue/Done) FSM that reads Smithy's argv slice into a SmithyConfig; pure, no env/fs/process access |
read_flags |
fn | config/flag_reader.rs |
Convenience: read an argv slice into a SmithyConfig with a fresh FlagReader |
SmithyConfig |
struct | config/flag_reader.rs |
Resolved launch config: profile, out, model, non_interactive, answers |
FlagSpec |
struct | config/flag_reader.rs |
One row of the recognised-flag table: public name (a SmithyFlagName) and spellings, plus a private kind (Switch/Single/Pair) |
FLAG_TABLE |
const | config/flag_reader.rs |
The single source of recognised flags (public from config, not flattened into smithy/mod.rs) |
SmithyFlagName |
enum | config/flag_reader.rs |
Profile / Out / Model / NonInteractive / Answer |
FlagError |
struct | config/flag_reader.rs |
thiserror-derived error raised when an argv slice cannot be read into a valid SmithyConfig |
persona — blueprint, profiles, generator
| Name | Kind | Source | Purpose |
|---|---|---|---|
AgentBlueprint |
struct | persona/blueprint.rs |
The validated agent spec: name (kebab-case), description, system_prompt (serde systemPrompt), tool_collection (serde toolCollection), model, optional tags |
BlueprintError |
struct | persona/blueprint.rs |
Raised when a candidate fails validation; carries issues: Vec<String> of path: message strings |
ToolCollectionName |
enum | persona/blueprint.rs |
ReadOnly / Coding / All — the granted tool surface, kebab-case on the wire |
validate_blueprint |
fn | persona/blueprint.rs |
Validate a serde_json::Value as a blueprint, collecting every issue into a BlueprintError |
try_validate_blueprint |
fn | persona/blueprint.rs |
Non-Result variant: returns Option<AgentBlueprint> |
serialize_blueprint |
fn | persona/blueprint.rs |
Serialize to canonical, fixed-key-order, 2-space JSON (camelCase keys); omits tags when empty |
deserialize_blueprint |
fn | persona/blueprint.rs |
Parse JSON text back into a validated blueprint; bad JSON and schema failures both surface as BlueprintError |
AgentSpec |
struct | persona/define_agent.rs |
The minimum a caller supplies (name required; everything else Option) |
define_agent |
fn | persona/define_agent.rs |
The single generator: merge AgentSpec over a named profile over a baseline, then validate into an AgentBlueprint |
to_agent_config |
fn | persona/define_agent.rs |
Project a validated blueprint onto the runtime AgentConfig, resolving the tool collection into a real ToolBox |
PROFILES |
fn | persona/profiles.rs |
Returns the interned starter-profile table &[(ProfileName, AgentProfile)] |
PROFILE_NAMES |
const | persona/profiles.rs |
[ProfileName; 3] in order, for menus and validation |
AgentProfile |
struct | persona/profiles.rs |
A partial blueprint with name removed; every field Option |
ProfileName |
enum | persona/profiles.rs |
Coder / Researcher / Reviewer |
get_profile |
fn | persona/profiles.rs |
Look up a profile by name, returning Option<AgentProfile> |
is_profile_name |
fn | persona/profiles.rs |
Whether a string names a known profile |
knowledge — the embedded guide pack
| Name | Kind | Source | Purpose |
|---|---|---|---|
load_knowledge |
fn | knowledge/loader.rs |
Read the include_dir!-embedded manifest.json + markdown guide bodies into a KnowledgePack |
KnowledgePack |
struct | knowledge/loader.rs |
The pack: version + Vec<Guide>, with a guide(id) lookup |
Guide |
struct | knowledge/loader.rs |
A fully-loaded guide: manifest metadata plus the markdown body |
GuideManifestEntry |
struct | knowledge/loader.rs |
One guide's manifest entry before its body is attached |
KnowledgeManifest |
struct | knowledge/loader.rs |
Parsed shape of manifest.json: version + Vec<GuideManifestEntry> |
KnowledgeError |
struct | knowledge/loader.rs |
Raised when the pack cannot be loaded or fails its shape check |
runtime — session bookkeeping
| Name | Kind | Source | Purpose |
|---|---|---|---|
ToolLedger |
struct | runtime/tool_ledger.rs |
Append-only account of tool executions: record, entries, summary, size, reset |
ToolEvent |
struct | runtime/tool_ledger.rs |
A single invocation to remember: tool, input, output, ok |
LedgerEntry |
struct | runtime/tool_ledger.rs |
A settled row: the event fields plus stamped seq (1-based) and at (epoch ms) |
LedgerSummary |
struct | runtime/tool_ledger.rs |
Point-in-time digest: total/ok/failed/tools rollups plus a by_tool map of ToolTally |
ToolTally |
struct | runtime/tool_ledger.rs |
Per-tool slice: calls/ok/failed |
ui — the render seam
| Name | Kind | Source | Purpose |
|---|---|---|---|
TranscriptModel |
struct | ui/transcript.rs |
Table-driven visitor: accept(turns) sanitizes and projects gateway Turns into a RenderTranscript; stateless |
ForgeView |
trait | ui/transcript.rs |
The terminal-free text seam: show(line) and async ask(question) |
ConsoleForgeView |
struct | ui/transcript.rs |
Default view: writes lines to stdout, reads a line off stdin per ask |
format_transcript |
fn | ui/transcript.rs |
Format a RenderTranscript into flat labelled lines for a ForgeView::show pipe |
RenderTranscript |
struct | ui/transcript.rs |
Result of accept: a turns: Vec<RenderTurn> plus a redaction_count |
RenderTurn |
struct | ui/transcript.rs |
A projected turn: a RenderRole plus its surviving RenderBlocks |
RenderBlock |
struct | ui/transcript.rs |
One sanitized fragment: kind, label, text, redacted |
SanitizeOptions |
struct | ui/transcript.rs |
Tunable thresholds: max_length (default 2000) and extra secret_patterns |
DEFAULT_SANITIZE_OPTIONS |
fn | ui/transcript.rs |
Default options used when a caller passes none |
default_visitors |
fn | ui/transcript.rs |
The default VisitorTable — one pure visitor per block kind |
VisitContext |
struct | ui/transcript.rs |
Per-block context: a &SanitizeOptions and the turn's RenderRole |
BlockVisitor |
type | ui/transcript.rs |
Box<dyn Fn(&Block, &VisitContext) -> Option<RenderBlock> + Send + Sync> |
VisitorTable |
struct | ui/transcript.rs |
The dispatch table: one BlockVisitor per block kind |
RenderBlockKind |
enum | ui/transcript.rs |
Text / Thinking / ToolCall / ToolResult / Image / Command |
RenderRole |
enum | ui/transcript.rs |
User / Assistant / Tool |
forge — the build session
| Name | Kind | Source | Purpose |
|---|---|---|---|
Forge |
struct | forge.rs |
The build-session conductor: build_interactive, build_from_config, instantiate, plus ledger() and transcript() |
create_forge |
fn | forge.rs |
Thin factory returning a Forge; takes ForgeOptions |
ForgeOptions |
struct | forge.rs |
Injectable collaborators: a full Arc<dyn ForgeView>, or an ask-only AskFn override |
AskFn |
type | forge.rs |
Arc<dyn Fn(String) -> Pin<Box<dyn Future<Output = String> + Send>> + Send + Sync> — the boxed async prompt seam |
InterviewKey |
enum | forge.rs |
Name / Purpose / Tools / Model — the interview answer keys |
InterviewAnswers |
struct | forge.rs |
Collected interview answers, one Option<String> per key |
Blueprints and tool collections
An AgentBlueprint is the small declarative spec that fully determines a generated
agent. It is data plus validation — the TS used a zod schema; the Rust port keeps
the shape as a serde-derived struct and validates by hand. The wire shape is
camelCase via #[serde(rename = …)]:
pub struct AgentBlueprint {
pub name: String, // kebab-case, 1..=64 chars
pub description: String,
#[serde(rename = "systemPrompt")]
pub system_prompt: String,
#[serde(rename = "toolCollection")]
pub tool_collection: ToolCollectionName,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}
name must match ^[a-z0-9][a-z0-9-]*$ and be 1–64 characters. The tool surface a
blueprint grants is the three-variant ToolCollectionName, named to match — and
kept intentionally identical to — the collections the Capabilities layer
publishes:
| Variant | Wire string (as_str) |
Grants |
|---|---|---|
ToolCollectionName::ReadOnly |
"read-only" |
Observe the workspace and the web; never mutate |
ToolCollectionName::Coding |
"coding" |
Read-only plus write/edit/bash/todo/process |
ToolCollectionName::All |
"all" |
Every registered tool |
The enum serializes via #[serde(rename_all = "kebab-case")], so a blueprint's JSON
matches the TS edition byte-for-byte.
Validation and serialization
validate_blueprint(candidate: &serde_json::Value) -> Result<AgentBlueprint, BlueprintError>
is the single gate everything downstream trusts. Like the original zod schema, it
collects every issue before returning — an empty name, for instance, surfaces
both the min-length and the kebab-case violation — and renders each in path: message
form. Unknown keys are stripped (zod z.object semantics), an absent or explicit
JSON null tags maps to None, and a non-object candidate reports (root): … and
stops.
use indusagi::smithy::{validate_blueprint, BlueprintError};
use serde_json::json;
let bp = validate_blueprint(&json!({
"name": "code-reviewer",
"description": "Reviews code.",
"systemPrompt": "You review code.",
"toolCollection": "read-only",
"model": "claude-sonnet-4",
}))?;
assert_eq!(bp.tool_collection, indusagi::smithy::ToolCollectionName::ReadOnly);
# Ok::<(), BlueprintError>(())
BlueprintError is a thiserror-friendly struct whose Display is
invalid agent blueprint: {issues joined by "; "}. The serde helpers round-trip
through canonical JSON:
serialize_blueprintemits keys in a fixed order (name,description,systemPrompt,toolCollection,model, thentagsonly when non-empty) with 2-space indent, so two equal blueprints always produce byte-identical text.deserialize_blueprintparses JSON text and runs it throughvalidate_blueprint; malformed JSON surfaces as a singlenot valid JSON: …issue, schema failures as the usualpath: messageissues.try_validate_blueprintis theOption-returning variant for callers that prefer to branch.
Starter profiles
A AgentProfile is a named partial blueprint with name removed — the one field a
profile never decides on the caller's behalf. Every other field is Option, and an
absent field falls through to the define_agent baseline. Three ship in the
PROFILES() table:
Profile (ProfileName) |
Tool collection | Model | For |
|---|---|---|---|
Coder ("coder") |
Coding |
claude-sonnet-4 (BALANCED_MODEL) |
Reads, edits, and writes code; runs commands to land changes |
Researcher ("researcher") |
ReadOnly |
claude-sonnet-4 (BALANCED_MODEL) |
Gathers, cross-checks, and summarises from files and the web |
Reviewer ("reviewer") |
ReadOnly |
claude-opus-4 (HEAVY_MODEL) |
Inspects changes for correctness and quality, reports findings |
Each profile pins a tool collection, a baseline model, a multi-line starter system
prompt, a description, and tags. The reviewer is the one profile backed by the heavy
model. The table is interned once behind a OnceLock; PROFILES() and the private
profiles() hand back the same slice. Adding a new starter agent is a single entry
here, not a new function.
use indusagi::smithy::{get_profile, is_profile_name, PROFILE_NAMES, ToolCollectionName};
assert!(is_profile_name("coder"));
let coder = get_profile("coder").unwrap();
assert_eq!(coder.tool_collection, Some(ToolCollectionName::Coding));
assert_eq!(PROFILE_NAMES.len(), 3);
get_profile is case-sensitive and does no trimming (get_profile("CODER") and
get_profile("coder ") both return None), and is_profile_name agrees with it on
every input.
The merge generator
define_agent is the one generator. It merges three sources highest-first — the
caller's spec, then the named profile's defaults, then a module baseline() — and
funnels everything through the single validate_blueprint gate.
pub fn define_agent(
spec: &AgentSpec,
profile: Option<&str>,
) -> Result<AgentBlueprint, BlueprintError>;
The precedence chain is implemented with a first_string(a, b, fallback) helper (and
pick_collection for the enum) that mirrors the TS ?? chain: only a None field
falls through. A Some("") from the spec wins the merge and is then rejected by
the validation gate — deliberate and predictable. spec.tags replace (do not merge
with) the profile's tags (spec.tags.as_ref().or(seed.tags.as_ref())). The
AgentSpec carries only name mandatorily:
pub struct AgentSpec {
pub name: String,
pub description: Option<String>,
pub system_prompt: Option<String>,
pub tool_collection: Option<ToolCollectionName>,
pub model: Option<String>,
pub tags: Option<Vec<String>>,
}
A missing profile (None or Some("")) seeds an empty AgentProfile::default(); an
unknown non-empty profile name is reported as a BlueprintError with the single
issue unknown profile "{name}" — not a separate error type — so callers handle one
failure shape. The baseline supplies description = "A general-purpose agent.",
tool_collection = ReadOnly, model = "claude-sonnet-4", and a four-line
general-purpose system prompt.
use indusagi::smithy::{AgentSpec, define_agent, serialize_blueprint, ToolCollectionName};
// Seed from the 'coder' profile; the spec wins on any field it sets.
let mut spec = AgentSpec::new("code-reviewer");
spec.model = Some("claude-opus-4".to_string());
let bp = define_agent(&spec, Some("coder")).unwrap();
assert_eq!(bp.name, "code-reviewer");
assert_eq!(bp.tool_collection, ToolCollectionName::Coding); // from profile
assert_eq!(bp.model, "claude-opus-4"); // from spec
println!("{}", serialize_blueprint(&bp)); // canonical JSON
The runtime bridge
to_agent_config is the second half of persona: it projects a validated blueprint
onto the runtime's AgentConfig.
pub fn to_agent_config(blueprint: &AgentBlueprint, cwd: Option<String>) -> AgentConfig;
It maps system_prompt to the config's system preamble, passes model through,
and resolves the named tool_collection into a runnable toolbox: the capabilities
tool_box(collection, cwd) is wrapped by the runtime CapabilitiesToolBox bridge
and stored as Arc<dyn ToolBox> on AgentConfig::tools. cwd roots the resulting
tools at a working directory (defaulting to the process cwd inside tool_box). The
returned config's toolbox advertises exactly the chosen collection's descriptors —
a Coding blueprint exposes read, edit, bash, and so on. See
Runtime for AgentConfig and ToolBox.
The Forge build session
Forge is the conductor that wires every sibling layer into one drivable build
session. Construct it with create_forge(options: ForgeOptions) (or Forge::new),
then drive it in one of two modes and instantiate the result.
pub struct ForgeOptions {
pub view: Option<Arc<dyn ForgeView>>,
pub ask: Option<AskFn>,
}
build_interactive(seed_profile: Option<&str>) walks a fixed four-step interview
(Name, Purpose, Tools, Model) through the view's show + ask. For each step
it records the prompt and answer as a user/assistant dialogue turn pair and
files one ToolLedger interview.ask event (whose ok is !answer.is_empty()),
then assemble maps the answers into an AgentSpec and calls define_agent, logging
a final blueprint.define event. The session resets its ledger and dialogue at
the start of each call. It is async and returns Result<AgentBlueprint, BlueprintError>.
build_from_config(cfg: &SmithyConfig) does the same assembly with answers pulled
from cfg.answers, the profile from cfg.profile, and an explicit cfg.model
overriding any answer-supplied one — with zero I/O and no async.
instantiate(blueprint, cwd) calls create_agent(to_agent_config(blueprint, cwd), AgentDeps::default())
to mint a runnable Agent.
use indusagi::smithy::{create_forge, ForgeOptions, SmithyConfig};
use std::collections::BTreeMap;
let forge = create_forge(ForgeOptions::default());
let mut answers = BTreeMap::new();
answers.insert("name".into(), "doc-finder".to_string());
answers.insert("purpose".into(), "Find and summarise docs".to_string());
answers.insert("tools".into(), "read-only".to_string());
let cfg = SmithyConfig {
profile: Some("researcher".to_string()),
answers: Some(answers),
..Default::default()
};
let bp = forge.build_from_config(&cfg)?;
let agent = forge.instantiate(&bp, Some("/repo".to_string())); // -> runtime Agent
# Ok::<(), indusagi::smithy::BlueprintError>(())
The view (and its lone ask) is injected, never constructed inside the builder.
resolve_view uses an explicit view as-is; an ask-only option is wrapped in an
AskView whose show is a no-op; with neither, a real ConsoleForgeView over stdio
backs the session. That makes the whole interview scriptable with canned answers and
no terminal or network — the AskFn is a shared, Send + Sync, boxed async closure
so a Forge can be driven across .await points and threads:
use indusagi::smithy::{create_forge, ForgeOptions, AskFn};
use std::sync::{Arc, Mutex};
use std::future::Future;
use std::pin::Pin;
let script = Arc::new(Mutex::new(0usize));
let answers = ["my-agent", "Audit the build", "coding", ""];
let ask: AskFn = Arc::new(move |_q: String| {
let cursor = Arc::clone(&script);
Box::pin(async move {
let mut i = cursor.lock().unwrap();
let a = answers.get(*i).copied().unwrap_or_default().to_string();
*i += 1;
a
}) as Pin<Box<dyn Future<Output = String> + Send>>
});
let mut forge = create_forge(ForgeOptions { ask: Some(ask), ..Default::default() });
// forge.build_interactive(Some("coder")).await
// -> blueprint.name == "my-agent"; forge.ledger().summary().total == 5
The ledger total is 5: one interview.ask event per question (four) plus the final
blueprint.define event. The Tools answer is interpreted by as_tool_collection,
which lowercases and trims the free text and matches it against the three collection
names (blank or unrecognised answers fall through to the profile default).
CLI flags
FlagReader is a strict three-state finite-state machine
(ExpectFlag → ExpectValue → Done) that walks Smithy's own argv slice
token-by-token against the data-driven FLAG_TABLE. It is pure — no env, no
filesystem, no process access — accumulates a SmithyConfig, and is reusable (each
read call resets its internal state). It supports long and short spellings,
glued --flag=value, a -- terminator (after which Smithy ignores all positionals),
and rejects a trailing value-flag missing its value, an unrecognised flag, a switch
given a glued value, and a malformed/empty-keyed --answer pair.
pub fn read_flags<S: AsRef<str>>(argv: &[S]) -> Result<SmithyConfig, FlagError>;
Canonical (SmithyFlagName) |
Spellings | Kind |
|---|---|---|
Profile |
--profile, -p |
single |
Out |
--out, --output, -o |
single |
Model |
--model, -m |
single |
NonInteractive |
--non-interactive, --batch, -y |
switch |
Answer |
--answer, -a |
pair (repeatable key=value) |
use indusagi::smithy::read_flags;
let cfg = read_flags(&[
"--profile", "coder", "-o", "out/agent.json",
"--answer", "name=fixer", "-a", "purpose=fix bugs", "-y",
])?;
assert_eq!(cfg.profile.as_deref(), Some("coder"));
assert_eq!(cfg.out.as_deref(), Some("out/agent.json"));
assert_eq!(cfg.non_interactive, Some(true));
// cfg.answers == { "name": "fixer", "purpose": "fix bugs" }
# Ok::<(), indusagi::smithy::FlagError>(())
A single flag is last-spelling-wins (overwrite). An --answer value may itself
contain = (it splits on the first separator only). A slice that cannot be read into
a valid config returns a FlagError whose Display is the bare message, e.g.
flag "--profile" expects a value.. See CLI for how Smithy's
argv slice is sourced.
The knowledge pack
The authoring guides Smithy consults are externalised data, not inline constants —
diffable markdown that travels with the binary. Where the TS read the pack off disk at
runtime, the Rust port embeds the whole directory at compile time with
include_dir!("$CARGO_MANIFEST_DIR/src/smithy/knowledge"), so load_knowledge() takes
no path and never touches the filesystem.
pub fn load_knowledge() -> Result<KnowledgePack, KnowledgeError>;
It reads and shape-checks manifest.json (a non-empty version string plus a
guides array whose entries each carry non-empty id, title, file, summary),
then resolves each guide's body from the embedded tree (paths are relative to the
manifest root, e.g. guides/authoring-an-agent.md). A listed-but-missing or
malformed input surfaces as a KnowledgeError (Display is the bare message). The
shipped pack is version 1.0.0 with four guides:
| Guide id | Title | Covers |
|---|---|---|
authoring-an-agent |
Authoring an agent | The order of decisions: start from a profile, name it like a file, write a description, let define_agent merge and validate |
choosing-tools |
Choosing tools | The three nested tool collections, picking the narrowest, why read-only is a real guarantee |
writing-system-prompts |
Writing system prompts | Lead with the role, prefer sharp rules, state access, say what to do when stuck |
model-selection |
Model selection | Matching a catalog model id to a workload, spending deliberately, right-sizing |
use indusagi::smithy::load_knowledge;
let pack = load_knowledge()?;
assert_eq!(pack.version, "1.0.0");
let guide = pack.guide("choosing-tools").expect("present");
assert_eq!(guide.title, "Choosing tools");
assert!(guide.body.contains("read-only"));
# Ok::<(), indusagi::smithy::KnowledgeError>(())
KnowledgePack::guide(id) is the lookup index over guides.
The tool ledger
ToolLedger is the stateful, append-only account of what a build session did. It
keeps an append-only log: Vec<LedgerEntry> and a set of derived aggregates that
are advanced incrementally on each append rather than re-folded — so summary() reads
already-settled numbers. record(event, at) stamps a LedgerEntry with a 1-based
seq and an epoch-ms at (defaulting via crate::core::now_ms() when None),
returns it, and advances both the per-tool counters (an insertion-ordered
IndexMap, mirroring the TS Map order) and the session rollups. reset() returns
the ledger to its just-constructed state and restarts seq numbering.
use indusagi::smithy::{ToolLedger, ToolEvent, ToolTally};
use serde_json::json;
let mut ledger = ToolLedger::new();
ledger.record(ToolEvent {
tool: "read".to_string(),
input: Some(json!({ "path": "/etc/hosts" })),
output: Some(json!({ "bytes": 42 })),
ok: true,
}, None);
ledger.record(ToolEvent { tool: "bash".to_string(), input: None, output: None, ok: false }, None);
let s = ledger.summary();
assert_eq!(s.total, 2);
assert_eq!(s.ok, 1);
assert_eq!(s.failed, 1);
assert_eq!(s.tools, 2);
assert_eq!(s.by_tool["read"], ToolTally { calls: 1, ok: 1, failed: 0 });
LedgerSummary carries total/ok/failed/tools plus a by_tool: BTreeMap<String, ToolTally>,
where each ToolTally's failed is always calls - ok. entries() returns a
detached clone of the log (oldest-first) so a caller cannot mutate the ledger's
history, and a summary() snapshot is independent of later records.
The transcript projector and view seam
TranscriptModel is a table-driven, stateless visitor over a build transcript —
an ordered list of gateway Turns, each a bag of typed Blocks. accept(turns)
visits every turn in order, dispatches each block through a VisitorTable (one
BlockVisitor per Block kind), and returns a RenderTranscript (a Vec<RenderTurn>
plus a redaction_count). A visitor returning None drops its block, but a turn is
kept even when all its blocks drop, so the speaker sequence is preserved.
Each block's salient text runs through sanitize_body, a fixed three-stage pipeline:
- Mask secrets first (so the mask itself is never length-measured or
whitespace-split): built-in regexes cover vendor-prefixed keys (
sk-/pk-/rk-),gh[opsu]_GitHub tokens,xox[baprs]-Slack tokens,AKIA…AWS keys,AIza…Google keys,Bearer …auth headers, andKEY=value/"api_key": "…"style assignments. Matches become‹redacted-secret›. A caller's extraSanitizeOptions.secret_patternsrun on top. - Collapse whitespace: fold CRLF to
\n, runs of spaces/tabs to one space, newline-hugging spaces away, 3+ blank lines to two, then trim the JS WhiteSpace set (spelled explicitly to match ECMAScript, not Rust's wider Unicode set). - Length cap: a body over
SanitizeOptions.max_length(default2000, measured in UTF-16 code units for JSString.lengthparity) is replaced by‹redacted blob — {n} chars omitted›.
A fragment is redacted: true if a secret was masked or the body was truncated.
Images never carry their bytes into the projection — they become a stable descriptor
(‹image/png image — {n} bytes omitted›) and are always redacted. Labels are derived:
a text block's label is its turn role, a tool call is tool → {name}, a result is
result · {ok|error}, a command is command · {command}.
format_transcript(&RenderTranscript) flattens the result into labelled lines (a ⚠
marker on redacted blocks, a blank line between turns, no trailing blank), ready for
ForgeView::show.
use indusagi::smithy::TranscriptModel;
use indusagi::llmgateway::contract::{Turn, Block};
let turns = vec![Turn::User {
blocks: vec![Block::Text { text: "key sk-ABCDEFGHIJ1234567890 done".into() }],
}];
let rendered = TranscriptModel::default().accept(&turns);
assert_eq!(rendered.redaction_count, 1); // secret masked
ForgeView is the two-method async trait (fn show(&self, line: &str) +
async fn ask(&self, question: &str) -> String) the builder talks through, declared
with #[async_trait::async_trait]; ConsoleForgeView is the stdio default (it
println!s shown lines and reads one line off stdin per ask). No terminal toolkit
is imported in this layer — a richer view (a TUI, a web pane) is slotted in later by
implementing the trait.
Relationship to neighbors
Smithy is a leaf subsystem of the merged indusagi crate and a downstream
consumer, not a dependency, of the other layers. It imports:
ToolCollectionandtool_boxfrom the Capabilities layer, andAgentConfig,CapabilitiesToolBox,ToolBox,Agent,AgentDeps, andcreate_agentfrom Runtime — the conductor's instantiation target and the toolbox bridge. (Note the twoToolBoxes: the capabilitiestool_boxreturns the OS-backedcapabilities::kernel::ToolBox, whichto_agent_configwraps in aCapabilitiesToolBoxto satisfy the runtime'sruntime::contract::ToolBoxtrait.)ToolCollectionNameis a local enum kept identical to the named collections so the blueprint schema validates standalone.- the conversation types
BlockandTurn(with their variants —Block::Text,Thinking,ToolCall,ToolResult,Image,Command;Turn::User,Assistant,Tool) from the LLM Gateway contract, whichTranscriptModelvisits. crate::core::now_msfrom Core for the ledger's defaultatstamp.
No other indusagi area imports Smithy. Within the subsystem, forge.rs depends on
all five sibling layers, persona/define_agent bridges persona into runtime and
capabilities, and ui depends only on the gateway contract. For the broader layering
and how this maps onto the Python and TS editions, see
Architecture.
