jagent is a review-first, sans-IO Rust core for terminal AI agents. It owns
the bounded session state machine, provider wire formats, protocol parsing,
request preparation, streaming accumulation, and command-review invariants.
Your integration owns HTTP, process execution, durable storage, and UI.
jsh and forge use it directly. jterm_core carries the shared integration used by anvil, ember, forge, and frost.
| Capability | Contract |
|---|---|
| Providers | Anthropic Messages, OpenAI-compatible Chat Completions, and Ollama /api/chat |
| Action protocols | Strict JSON-in-text and provider-native tools |
| Delivery | Bounded complete-response decoding and incremental SSE/NDJSON streaming |
| Execution | Never performed by this crate; every command becomes a proposal requiring explicit approval |
| Runtime coupling | None: no HTTP client, async runtime, process, PTY, storage, or UI dependency |
| Rust baseline | Rust 1.86 (edition 2021) |
Documentation:
- Integration guide — transport, streaming, review, failure, and persistence ownership.
- Quickstart example — a complete non-streaming Text turn with a local response fixture.
- Streaming example — chunked native-tool streaming through the same review state machine.
- Migration notes — compatibility guidance for
jterm_coreand existing terminal consumers. - Changelog — release history and compatibility notes.
The recommended 0.7 path keeps the request protocol, matching system prompt, redaction policy, response decoder, and session ingestion together:
- Submit user input to an
AgentSession. - Encode terminal/environment context as untrusted user-role data.
- Call
prepare_agent_request; high-confidence secret redaction is on by default, with counts for retained-turn redaction, elision, and omission. - Perform the returned HTTP request with your own transport.
- Decode bytes with
prepared.parse_response; the prepared request keeps the provider, protocol, and non-streaming delivery mode paired for you. - Display every proposed command. Only
approvereturns anApprovedCommand, and the integration must still execute it deliberately.
Run the repository example:
cargo run --example quickstart
It performs no network or process I/O. It builds a request for a loopback OpenAI-compatible endpoint, checks the preparation report, then feeds a local response fixture through the review state machine.
use jagent::{
agent_user_prompt, prepare_agent_request, AgentProtocol, AgentRequestSpec,
AgentSession, ChatConfig, EnvironmentMeta, Message, ModelOutcome, Provider,
Role,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let protocol = AgentProtocol::Text;
let mut session = AgentSession::new(8);
// Deliberately resembles a leaked token so the secure default is visible.
const SECRET: &str = "ghp_1234567890abcdefghijABCDEFGHIJ123456";
session.submit_user(format!(
"show the current directory; an accidental token was pasted: {SECRET}"
))?;
let environment = EnvironmentMeta {
cwd: "/workspace/jagent".into(),
shell: "bash".into(),
os: "linux".into(),
git: None,
};
let history = [Message {
role: Role::User,
text: agent_user_prompt(
&session.build_user_prompt_with(protocol),
&environment,
None,
),
}];
let config = ChatConfig {
provider: Provider::OpenAiCompatible,
api_key: None,
model: "local-agent".into(),
base_url: "http://127.0.0.1:1234".into(),
max_tokens: 512,
temperature: Some(0.0),
};
let prepared = prepare_agent_request(
&config,
AgentRequestSpec::new(&history, protocol),
)?;
assert_eq!(
prepared.request.url,
"http://127.0.0.1:1234/chat/completions"
);
assert!(prepared.report.redaction_enabled);
assert_eq!(prepared.report.history.changed_history_turns, 1);
assert_eq!(prepared.report.history.omitted_history_turns, 0);
assert!(!prepared.request.body.contains(SECRET));
// A real integration sends `prepared.request` and supplies the bounded
// response bytes. This local fixture keeps the example completely sans-IO.
let response_bytes = br#"{
"choices": [{
"message": {
"role": "assistant",
"content": "{\"action\":\"run\",\"command\":\"pwd\"}"
},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 20, "completion_tokens": 8}
}"#;
let response = prepared.parse_response(response_bytes)?;
let outcome = session.accept_agent_response(&response)?;
let ModelOutcome::Proposal {
id,
command,
danger,
} = outcome
else {
panic!("fixture should produce a command proposal");
};
assert_eq!(command, "pwd");
assert!(danger.is_none());
// Approval only yields a value. jagent never starts the command.
let approved = session.approve(id)?;
assert_eq!(approved.command, "pwd");
// After the integration deliberately executes the approved value, it
// records the real exit status and output. Here both are simulated.
session.observe(approved.proposal_id, 0, "/workspace/jagent\n")?;
Ok(())
}EnvironmentMeta and optional BlockContext values are JSON-encoded inside
the user role. They are never interpolated into system instructions. Default
request preparation redacts the outbound Message; it does not mutate the
session transcript or claim to be a complete DLP system.
Select streaming declaratively on the same request specification. The prepared
request creates the matching AgentStream, so callers do not re-enter its
provider or protocol:
# use jagent::{prepare_agent_request, AgentProtocol, AgentRequestSpec, ChatConfig, Message, Provider};
# fn example(history: &[Message]) -> Result<(), Box<dyn std::error::Error>> {
# let config = ChatConfig::new(Provider::OpenAiCompatible);
let protocol = AgentProtocol::Text;
let prepared = prepare_agent_request(
&config,
AgentRequestSpec::new(history, protocol).streaming(true),
)?;
let mut response_stream = prepared.response_stream()?;
// Send `prepared.request` with your transport. For each received chunk:
// for event in response_stream.push(chunk) { render(event); }
// At transport EOF:
// for event in response_stream.finish() { render(event); }
// let response = response_stream.into_response()?;
// let outcome = session.accept_agent_response(&response)?;
# let _ = (&prepared.request, &mut response_stream);
# Ok(())
# }push and finish return the underlying StreamEvents unchanged while the
wrapper accumulates a high-level response. into_response succeeds only after
Done. Protocol failure, premature EOF, or a native tool call in Text mode
fails closed. A streamed ToolCall event is not execution authorization;
integrations should act only on the proposal produced after successful
response conversion and session ingestion. parse_response rejects a request
prepared for streaming, and response_stream rejects one prepared for a
complete response body. AgentStream::protocol reports the bound protocol and
is_complete reports whether the low-level Done marker was observed;
into_response remains the authoritative final validation step.
Run the no-I/O native-tools companion to exercise arbitrary chunk boundaries, stream completion, response conversion, and proposal approval:
cargo run --example streaming
Set AgentProtocol::NativeTools on AgentRequestSpec.
prepare_agent_request selects the matching tool-aware system prompt and
provider schema, and its prepared response path retains that protocol.
AgentResponse then contains a ToolResponse, but the session still converts
run into the same reviewed proposal as Text mode.
All built-in providers support the two agent protocols and their streaming forms:
| Provider | Text | NativeTools | Streaming |
|---|---|---|---|
| Anthropic Messages | Yes | Yes | Yes |
| OpenAI-compatible Chat Completions | Yes | Yes | Yes |
Ollama /api/chat |
Yes | Yes | Yes |
Ollama uses the function schema documented by its
/api/chat API and
tool-calling guide, while
omitting the undocumented tool_choice field. Exactly one tool call is
required; zero, multiple, malformed, token-limited, or protocol-mismatched
calls fail closed.
Split integrations can exchange one bounded, non-sensitive capability token
before choosing a wire protocol. agent_capabilities(provider) emits the
version-1 compatibility form that existing 0.7 peers understand. After a peer
token has been decoded, agent_capabilities_for_peer emits the same provider
matrix in that peer's schema version. negotiate selects the first mutually
supported protocol from the caller's preference order and never guesses a
fallback:
use jagent::{
agent_capabilities, agent_capabilities_for_peer, AgentCapabilities,
AgentDelivery, AgentProtocol, Provider,
};
let first_contact = agent_capabilities(Provider::Ollama);
assert_eq!(first_contact.version(), 1); // safe for an unprobed 0.7 peer
let peer = AgentCapabilities::from_wire(
"jagent-agent/2;modes=text+complete,native-tools+streaming",
)?;
let local = agent_capabilities_for_peer(Provider::Ollama, peer);
assert_eq!(local.version(), peer.version());
let protocol = local
.negotiate_with(
peer,
&[AgentProtocol::NativeTools, AgentProtocol::Text],
AgentDelivery::Complete,
)
.ok_or("no mutually supported Agent protocol")?;
# Ok::<(), Box<dyn std::error::Error>>(())Version 2 tokens list exact protocol/delivery pairs, so a peer can advertise
jagent-agent/2;modes=text+complete,native-tools+streaming without falsely
claiming either crossed combination. Strict version-1 tokens such as
jagent-agent/1;protocols=text;delivery=complete remain accepted and retain
their historical Cartesian-product meaning. Names, duplicates, non-canonical
ordering, empty sets, unknown fields/versions, whitespace, and values over 256
bytes are rejected. Tokens contain no endpoint, credential, model, transcript,
or terminal context. Capability agreement selects only an encoding; it never
authorizes a tool call or command. Do not send the opt-in
agent_capabilities_v2 value to an unprobed peer: an older peer correctly
rejects it as an unsupported version. If a future provider matrix is not a
Cartesian product, compatibility-first v1 emission chooses a representable
subset and may omit a usable mode; it never invents a crossed combination.
- Generated commands are never executed by this crate. Approval returns an
ApprovedCommand; the caller must deliberately hand it to an executor. - Ambiguous JSON fails closed. Outbound request validation and inbound complete responses, streaming frames, text actions, and native-tool arguments reject duplicate object members at every depth instead of inheriting a parser's first/last-value rule. The private serde_json RawValue escape key is rejected before a feature-unified decoder can reparse its string; parse failure never becomes a command proposal.
- Transcripts, observations, request history, encoded prompt contexts, response envelopes, streamed frames, model text, and tool arguments are byte-bounded.
- Terminal output and environment metadata travel as explicitly untrusted user-role data, never as system instructions. Context budgets are enforced after JSON encoding, and untrusted values cannot spell their raw enclosing closing tag.
- Every proposal requires explicit approval.
is_auto_approvableremains as a compatibility hook and always returnsfalse. - Snapshot restore revalidates transcript bounds, command shape, model-turn accounting, strictly increasing proposal IDs, final-turn state bindings, and the adjacent proposal/observation lifecycle. Persisted text cannot cover an older approval card or erase an approved command's outcome.
- Native tool calls are withheld by the low-level stream parser until their enclosing response completes. Token-limited output is never promoted to an action.
is_dangerous provides review warnings only. It neither authorizes nor blocks
execution, and command-text heuristics cannot prove what a configured shell,
alias, function, or helper will do. Its network-content warning follows a
pipeline through intermediate filters before an interpreter, rather than
assuming one benign-looking stage makes downloaded bytes trustworthy. It also
looks through a bounded chain of xargs dispatchers while consuming their
option values, so network-controlled argv cannot hide a shell behind
xargs -0/-n/-I. The same dispatcher walk reviews its fixed child argv, so
xargs rm -rf / or a nested destructive Git command retains the warning it
would receive without xargs; runtime data is not guessed.
Classification also interprets shell ANSI-C quoted executable/script text, so
the review warning describes the command after $'\xNN', octal, or Unicode
escape expansion rather than trusting its encoded spelling. Attached and
leading redirections are tokenized separately from argv—including numeric and
{named} fd prefixes—so --hard>log or 2>/dev/null git clean -fdx cannot
change which command and fixed arguments receive review.
GNU env -S / --split-string wrappers are decoded with their own whitespace,
quote, comment, and escape grammar before the same review. Generated argv is
combined with trailing argv and bounded through nested wrappers; unique GNU
long-option abbreviations are resolved too. ${NAME} expansion receives an
explicit warning because runtime state can change the executable after review,
while malformed variable names and the command-incompatible --null form stay
inert. Exceeding either dispatcher budget also warns instead of silently
treating the hidden child as safe.
Bash exec, command, and builtin prefixes retain their execution modes:
exec -a NAME still exposes the real executable, while command -v/-V, help,
invalid options, and non-existent builtin targets remain non-executing data
instead of producing a warning for their later arguments.
Children launched by env or xargs stay direct argv across nested external
wrappers: shell-only command, eval, and assignment prefixes are not
interpreted a second time. BusyBox env uses its smaller applet grammar, while
GNU-only -S remains invalid there. The same context-aware dispatcher walk
follows a fixed curl/wget child across a pipeline without treating an
uppercase or non-existent wrapper name as a network fetch.
Top-level external nohup, timeout, nice, GNU time, and exec children
also retain direct argv, so assignment-looking words and shell builtins are not
reparsed. A bare Bash time keyword remains shell syntax, including a following
command/builtin, while an explicit or command-resolved time executable
keeps its child direct.
Use validate_command_text before an integration copies a proposal into an
approval card, persistence adapter, or review-only shell insertion. It applies
the same public MAX_COMMAND_BYTES ceiling and exact single-line/control/bidi
contract as the session parser, while returning the original slice unchanged.
Success is a display/transport invariant, not execution authorization.
| Module | Responsibility |
|---|---|
agent |
AgentRequestSpec and prepare_agent_request: protocol-matched prompt/schema, secure history preparation, streaming selection, and a history preparation report. |
capabilities |
Versioned protocol/delivery discovery, bounded wire tokens, and deterministic preference negotiation. |
response |
AgentResponse and AgentStream: protocol-aware bounded decoding, response metadata, action conversion, and streaming accumulation. |
session |
Pure proposal/review/observation state machine, bounded transcript, turn budget, cancellation, and validated snapshots. |
prompt |
Fixed system prompts plus untrusted user-role environment and selected-block framing. |
provider |
Provider configuration and the lower-level Anthropic, OpenAI-compatible, and Ollama request/response codecs. |
tools |
Native tool schemas and low-level ToolResponse parsing. |
stream |
Low-level SSE/NDJSON StreamParser and StreamEvents. |
redact |
Conservative high-confidence secret scrubbing; the borrowing API is redact_secrets_cow. |
safety |
Non-authorizing destructive-command warnings. |
Secret-setting redaction is label-aware: quoted password, secret, API-key, and token values are removed as complete values even when they contain spaces, punctuation, or escaped quote characters. Unquoted labeled values also accept punctuation and backslash-escaped bytes; whitespace, structured-data closers, and unescaped shell operators remain value boundaries. Unlabeled opaque strings remain untouched to avoid erasing ordinary hashes and identifiers.
validate_no_duplicate_members exposes the same allocation-light recursive
JSON preflight used by these wire decoders. Integrations can apply it to other
already byte-bounded JSON trust boundaries before their own typed decode,
without constructing a second Value tree or learning a hostile field name
from the error. It also rejects serde_json's private RawValue sentinel, which a
feature-unified Value decoder would otherwise reinterpret as a second,
unchecked JSON document.
The 0.7 high-level path is additive. Existing integrations can migrate in stages:
provider::build_chat_request*andprovider::build_agent_chat_request*remain the low-level request builders. Compatibility variants return onlyHttpRequest;*_with_reportvariants returnBuiltRequestwith the omissions introduced by that build.provider::bound_historyandbound_history_withkeep their established tuple return types. New*_with_reportpreparation functions distinguish changed, elided, and fully omitted turns.provider::parse_chat_response*_bytesandtools::parse_tool_response_bytesremain bounded byte entry points. Theirserde_json::Valuecounterparts are only for trusted or already transport-bounded values. These lower-level parsers preserve compatibility with sparse legacy fixtures; high-levelAgentResponsedecoding requires a complete, unambiguous provider envelope before action parsing.session::accept_model_replyandaccept_model_tool_replyremain available when an integration deliberately manages protocol pairing itself.StreamParserremains the raw event parser for integrations that need to own accumulation.AgentStreamis the recommended protocol-aware wrapper.
AgentResponse::parse_bytes and AgentStream::new also remain available when
an integration deliberately pairs the provider, protocol, and delivery mode
itself. New code should normally decode through its PreparedAgentRequest so
those choices stay bound to the request that was sent.
Text-mode request builders retain their compatibility wire behavior. The
high-level path adds secure defaults and fuller reporting; disabling redaction
or replacing the built-in system prompt is explicit on AgentRequestSpec.
session::Turn, provider::Message, and prompt::BlockContext are
serialize-only in-memory values, not standalone unbounded decoding formats.
Persisted state must enter through AgentSessionSnapshot::from_json and then
AgentSession::restore. Allocation-free schema atoms such as Role,
ProposalId, ProposalStatus, and AgentState retain Serde decoding, but a
decoded atom does not validate a session.
PreparedAgentRequest::parse_response delegates to bounded
AgentResponse::parse_bytes, which enforces the shared non-streaming envelope
limit before one JSON decode. A prepared response stream inherits
StreamParser's raw-response, frame-count, per-frame, text, tool-argument, and
call limits. HTTP headers, redirect policy, deadlines, socket cancellation,
TLS policy, and process execution remain integration responsibilities.
See the integration migration notes for the
ownership boundary with jterm_core and existing consumers. Release changes
are recorded in CHANGELOG.md.
The repository follows the terminal-family Rust toolchain convention:
rust-toolchain.toml selects stable with the minimal profile plus rustfmt and
Clippy. The crate's declared MSRV is Rust 1.86, which CI checks separately.
The full local release gate is:
cargo fmt --all -- --check
cargo run --locked --example quickstart
cargo run --locked --example streaming
cargo check --locked --all-targets --all-features
cargo test --locked --all-targets --all-features --no-fail-fast
cargo test --locked --all-features --doc
cargo clippy --locked --all-targets --all-features -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --locked --all-features --no-deps
cargo package --locked --allow-dirty
See CONTRIBUTING.md for compatibility and test expectations. Potential vulnerabilities should be reported through SECURITY.md.
Licensed under either of
at your option.