Extending Perspt¶
Adding a Language Plugin¶
Language plugins implement the LanguagePlugin trait from perspt-core.
Create a new module in
crates/perspt-core/src/plugin/:pub struct GoPlugin; impl LanguagePlugin for GoPlugin { fn name(&self) -> &str { "go" } fn extensions(&self) -> &[&str] { &["go"] } fn key_files(&self) -> &[&str] { &["go.mod", "go.sum"] } fn detect(&self, path: &Path) -> bool { path.join("go.mod").exists() } fn get_init_action(&self, opts: &InitOptions) -> ProjectAction { ProjectAction::ExecCommand { command: format!("go mod init {}", opts.name), description: "Initialize Go module".into(), } } fn test_command(&self) -> String { "go test ./...".into() } fn syntax_check_command(&self) -> Option<String> { Some("go vet ./...".into()) } fn verifier_profile(&self) -> VerifierProfile { // Define capabilities for each verifier stage // with primary and fallback commands } }
Register in the
PluginRegistryAdd LSP config for
gopls
Designing Domain Adapters¶
The platform SDK establishes a modular separation of concerns: the core SRBN control plane (in perspt-sdk) owns scheduling, residual scoring, capability checking, ledger tracking, and telemetry monitoring, while the domain adapter owns the domain-specific logic.
To write a custom domain extension (for example, to support research manuscript compilation, cloud deployment stability, or databases), developers must implement the AgentDomainPackage trait.
Implementing the AgentDomainPackage Trait¶
A domain package is a Rust struct that implements the AgentDomainPackage trait. Below is a complete implementation example of a custom research domain package:
use perspt_sdk::{
AgentDomainPackage, DomainDetection, DomainId, DomainScope, EnergyModel,
ResidualClass, ResidualEvent, ResidualSchema, ResidualWeight, WorkspaceSnapshot,
CorrectionDirection, EnergyComponent, StabilityClaim
};
pub struct ResearchDomain;
impl AgentDomainPackage for ResearchDomain {
/// Returns the unique identifier of the domain.
fn domain_id(&self) -> DomainId {
DomainId::new("research")
}
/// Detects if the current project workspace contains files corresponding to this domain.
fn detect(&self, workspace: &WorkspaceSnapshot) -> DomainDetection {
let mut evidence = Vec::new();
for marker in ["paper.tex", "thesis.tex", "bibliography.bib"] {
if workspace.has_file_named(marker) {
evidence.push(format!("found academic manuscript file: {}", marker));
}
}
let activated = !evidence.is_empty();
DomainDetection {
domain: self.domain_id(),
activated,
confidence: if activated { 0.90 } else { 0.0 },
evidence,
}
}
/// Declares the list of residual classes this domain can emit.
fn residual_schema(&self, _scope: &DomainScope) -> ResidualSchema {
ResidualSchema::new(vec![
ResidualClass::Syntax, // LaTeX syntax check
ResidualClass::SymbolMismatch, // Missing citations
ResidualClass::InterfaceMismatch, // Broken cross-references
ResidualClass::Build, // pdflatex build failures
])
}
/// Configures the energy model, including tolerances, bounds, and weights.
fn energy_model(&self, scope: &DomainScope) -> EnergyModel {
use EnergyComponent::*;
use ResidualClass::*;
let weights = vec![
ResidualWeight::new(Syntax, Syn, 2.0),
ResidualWeight::new(Build, Syn, 4.0).with_hard_threshold(0.0),
ResidualWeight::new(SymbolMismatch, Str, 1.5),
ResidualWeight::new(InterfaceMismatch, Str, 1.0),
];
let mut model = EnergyModel::new("research", 0.10)
.with_correction_budget(5);
model.residual_weights = weights;
model.energy_tolerance = 0.0;
model.stability_claim = Some(StabilityClaim::not_claimed(format!(
"research scope: {}",
scope.label
)));
model
}
/// Maps residual events to directed correction prompts for the actuator.
fn correction_directions(&self, residuals: &[ResidualEvent]) -> Vec<CorrectionDirection> {
let mut directions = Vec::new();
for r in residuals {
match r.class {
ResidualClass::Syntax => {
directions.push(CorrectionDirection {
instruction: format!(
"Repair LaTeX syntax error: {}. Check brackets and escape characters.",
r.message
),
..Default::default()
});
}
ResidualClass::SymbolMismatch => {
directions.push(CorrectionDirection {
instruction: format!(
"Insert missing citation entry in bibliography.bib for reference: {}.",
r.message
),
..Default::default()
});
}
_ => {}
}
}
directions
}
}
Registering the Domain Adapter¶
Once implemented, register your custom package inside the DomainRegistry at the composition root in crates/perspt-cli/src/commands/agent.rs:
let mut domains = perspt_sdk::DomainRegistry::new();
domains.register(std::sync::Arc::new(ResearchDomain));
An explicit --domain id wins; otherwise the registry selects the best detection, falling back to the coding domain. Every domain must implement the same interface to maintain compatibility with the ledger and dashboard.
Adding an Agent Tool¶
Tools live under crates/perspt-agent/src/tools/ (catalog and executor in
mod.rs and executor.rs, sandboxing in sandbox.rs, builtin
handlers in handlers/, first-party families in families/). The
extension point is the open execution plane: a tool family contributes
catalog entries (perspt_sdk::ToolEntry) and registers one
CandidateToolHandler per tool name in the CandidateHandlerRegistry.
The governance wrapper (catalog -> validate -> budget -> certify -> execute
-> re-certify) is uniform and lives outside the handlers.
use perspt_agent::{
CandidateHandlerRegistry, CandidateToolHandler, CandidateWorkspace,
};
use perspt_agent::toolloop::EffectOutcome;
struct MyProbe;
#[async_trait::async_trait]
impl CandidateToolHandler for MyProbe {
async fn apply(
&self,
workspace: &CandidateWorkspace,
call: &perspt_sdk::ProviderToolCall,
entry: &perspt_sdk::ToolEntry,
) -> anyhow::Result<EffectOutcome> {
// Realize the effect against the reversible candidate.
}
}
Register the family at the composition root
(crates/perspt-cli/src/commands/agent.rs), through the same public path
the shipped system and DB explorer families use:
let mut handlers = CandidateHandlerRegistry::with_builtins();
handlers.register("my_probe", Arc::new(MyProbe))?;
runtime = runtime
.with_tool_family(vec![my_probe_entry()]) // catalog entries
.with_tool_handlers(handlers); // execution surface
Duplicate registration fails closed, so a family can never silently shadow a builtin. Handlers never decide admission, budgets, or certification — the admissibility kernel already did; they only realize the effect against the reversible candidate.
Adding a Prompt Section Library¶
Actor prompts are typed section libraries under
crates/perspt-core/prompts/ (session_bootstrap, graph_plan,
repository_explore, adjudicate, evidence_summarize). A library
is a directory of ordered Markdown sections with YAML frontmatter:
---
id: session_bootstrap/role
version: 1
role: system
required: true
max_bytes: 256
vars:
domain_id: { type: "BoundedText<64>" }
---
You are a governed {{domain_id}} agent.
perspt-prompt-macros compiles the sections at build time
(frontmatter.rs parses, validate.rs runs the codegen validation
list, emit.rs generates the section constructors). After any section
change:
perspt prompts lint- run the validation list (--bundlepoints it at an external bundle directory)perspt prompts manifest crates/perspt-core/prompts- regenerate the committedmanifest.tomlwith fresh content hashes
Adding a Benchmark Corpus Task¶
The optional evaluation corpus lives in crates/perspt-benchmark/corpus/
(30 hidden-oracle tasks; see its README.md). Each task directory holds:
task.json- user goal, shell-free hidden-check argv, expectation, one language tag (rust,python,mixed), one scale tag, and capability tagsfixture/- the only tree copied into the agent’s workspacehidden/- the oracle overlay copied only after the agent finishessolution/- a withheld reference overlay used only by validation
Validate with the credential-free authoring gate:
cargo run -p perspt-cli --features benchmark -- benchmark validate
The gate refuses fewer than 30 tasks, an imbalanced language/scale mix, insufficient capability coverage, a fixture that already passes its oracle, or a reference solution that does not pass.
Adding an LLM Provider¶
The genai adapter in perspt-core/src/llm_provider.rs handles providers.
To add a new provider:
Add the adapter kind to
str_to_adapter_kind()Map the env var in
new_with_config()Add the provider’s default model to
detect_provider_from_env()inperspt-core/src/llm_provider.rsUpdate the auto-detection priority in config
fn str_to_adapter_kind(provider: &str) -> AdapterKind {
match provider {
"openai" => AdapterKind::OpenAI,
"anthropic" => AdapterKind::Anthropic,
// ... existing providers ...
"newprovider" => AdapterKind::NewProvider,
_ => AdapterKind::OpenAI,
}
}
Adding a Starlark Policy¶
Create a .star file in the policy directory (~/.config/perspt/policies/):
# custom_policy.star
def check_file_write(path, content):
# Called before any file write.
if path.endswith(".env"):
return deny("Cannot write .env files")
return allow()
def check_command(cmd):
# Called before any command execution.
if "curl" in cmd and "http://" in cmd:
return prompt("Insecure HTTP request: " + cmd)
return allow()
The PolicyEngine loads all .star files from the policy directory automatically.