pub const ACP_SCHEMA_COMPATIBILITY: &str =
"agentclientprotocol/agent-client-protocol schema v0.12.2";
pub const ACP_SESSION_UPDATE_VARIANTS: &[&str] = &[
"user_message",
"user_message_chunk",
"agent_message_chunk",
"agent_thought_chunk",
"tool_call",
"tool_call_update",
"plan",
"available_commands_update",
"current_mode_update",
"config_option_update",
"session_info_update",
"session_redo",
"session_rollback",
"session_truncated",
];
pub const HARN_SESSION_UPDATE_EXTENSIONS: &[&str] = &[
"artifact",
"available_commands_update",
"fs_watch",
"handoff",
"hitl_request",
"hitl_resolved",
"live_session_client",
"log",
"progress",
"reminder_emitted",
"skill_activated",
"skill_deactivated",
"skill_narrow",
"skill_scope_tools",
"stance_transition",
"tool_search_query",
"tool_search_result",
"transcript_compacted",
"transcript_projected",
"worker_update",
];
pub const HARN_AGENT_EVENT_METHOD: &str = "_harn/agentEvent";
pub const HARN_PROVIDER_CATALOG_METHOD: &str = "_harn/providerCatalog";
pub const HARN_AGENT_EVENT_KINDS: &[&str] = &[
"boundary_failure",
"budget_circuit_breaker",
"budget_exhausted",
"code_librarian_query_nl_fallback",
"composition_child_call",
"composition_child_result",
"composition_error",
"composition_finish",
"composition_start",
"compass_routing_decision",
"control_outcome",
"daemon_watchdog_tripped",
"feedback_injected",
"final_wrapup",
"host_attachment",
"host_tool_result",
"input_guardrail_verdict",
"iteration_end",
"session_health",
"iteration_start",
"judge_decision",
"judge_started",
"loop_control_decision",
"loop_stuck",
"mcp_auth_required",
"mcp_catalog_changed",
"mcp_notification",
"model_job",
"orchestration_decision",
"pack_thinking_stripped",
"progress_reported",
"purpose_label",
"repair_output_contract_applied",
"require_successful_tools_violation",
"reserved_terminal_verify",
"scope_classifier_verdict",
"self_consistency_tie",
"session_closed",
"structural_validator_decision",
"subagent_join",
"subagent_stop",
"step_judge_decision",
"tool_batch_disposition",
"tool_call_audit",
"tool_format_override",
"turn_phase_changed",
"typed_checkpoint",
];
pub const HARN_TOOL_LIFECYCLE_EXTENSION_FIELDS: &[&str] = &[
"audit",
"changedPaths",
"data",
"durationMs",
"error",
"errorCategory",
"executionDurationMs",
"executor",
"health",
"intent",
"mutationStatus",
"parsing",
"rawInputPartial",
];
pub const HARN_CONTENT_EXTENSION_FIELDS: &[&str] =
&["permission_preview", "visible_delta", "visible_text"];
pub const HARN_PROMPT_RESULT_EXTENSION_FIELDS: &[&str] = &["terminal"];
pub const HARN_STAGED_WRITES_PENDING_FIELDS: &[&str] =
&["pendingCount", "totalBytes", "pendingWrites"];
pub const HARN_STAGED_WRITE_FIELDS: &[&str] = &["path", "kind", "byteDelta", "snapshotId"];
pub(super) fn harn_acp_extension_meta() -> serde_json::Value {
serde_json::json!({
"harn": {
"schemaCompatibility": ACP_SCHEMA_COMPATIBILITY,
"sessionUpdateExtensions": HARN_SESSION_UPDATE_EXTENSIONS,
"toolLifecycleExtensionFields": HARN_TOOL_LIFECYCLE_EXTENSION_FIELDS,
"contentExtensionFields": HARN_CONTENT_EXTENSION_FIELDS,
"promptResultExtensionFields": HARN_PROMPT_RESULT_EXTENSION_FIELDS,
"stagedWritesPendingFields": HARN_STAGED_WRITES_PENDING_FIELDS,
"stagedWriteFields": HARN_STAGED_WRITE_FIELDS,
"sandboxConfinement": harn_vm::llm::vm_value_to_json(
&harn_vm::process_sandbox::host_confinement(),
),
"processConfinement": super::confinement::initialize_meta(),
"extensionMethods": {
HARN_AGENT_EVENT_METHOD: {
"description": "Harn agent events that have no \
canonical ACP session/update mapping.",
"kinds": HARN_AGENT_EVENT_KINDS,
"schema": "https://harnlang.com/spec/harn-extensions/agent-event/v1",
},
HARN_PROVIDER_CATALOG_METHOD: {
"description": "Return the normalized provider/model catalog used by this runtime.",
"schema": harn_vm::provider_catalog::PROVIDER_CATALOG_SCHEMA_ID,
"schemaVersion": harn_vm::provider_catalog::PROVIDER_CATALOG_SCHEMA_VERSION,
},
super::types::ACP_METHOD_SESSION_PLAN_DOCUMENT_MUTATE: {
"description": "Optimistically edit, comment on, resolve, reopen, or approve the canonical collaborative plan document.",
"resultSchema": harn_vm::llm::plan::PLAN_DOCUMENT_SCHEMA_VERSION,
"conflict": {
"code": super::types::ACP_PLAN_REVISION_CONFLICT_CODE,
"schemaVersion": super::types::ACP_PLAN_REVISION_CONFLICT_SCHEMA,
},
},
harn_vm::orchestration::SESSION_VIEW_QUERY_METHOD: {
"description": "Return the harn.session_view.v1 projection for a live or persisted session.",
"schema": harn_vm::orchestration::SESSION_VIEW_SCHEMA,
"schemaVersion": harn_vm::orchestration::SESSION_VIEW_SCHEMA_VERSION,
},
harn_vm::agent_sessions::CANONICAL_HISTORY_BOUNDARIES_METHOD: {
"description": "Acknowledge canonical history positions after the owning transcript journal flushes.",
"schema": harn_vm::agent_sessions::CANONICAL_HISTORY_BOUNDARIES_SCHEMA,
"schemaVersion": 1,
},
},
"hostCapabilityOperations": {
"hypothesis": ["attest_event"],
"process": [
"exec",
"list_shells",
"get_default_shell",
"set_default_shell",
"shell_invocation"
]
},
"extensionContract": "https://harnlang.com/spec/harn-extensions/v1",
}
})
}
pub(super) fn non_empty_env(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub(super) fn configured_llm_route_for_capabilities(
) -> Result<(String, String), harn_vm::llm_config::ModelResolutionError> {
let provider = non_empty_env("HARN_LLM_PROVIDER")
.filter(|provider| !provider.eq_ignore_ascii_case("auto"))
.or_else(|| {
if std::env::var("LOCAL_LLM_BASE_URL").is_ok()
&& (non_empty_env("HARN_LLM_MODEL").is_some()
|| non_empty_env("LOCAL_LLM_MODEL").is_some())
{
Some("local".to_string())
} else {
None
}
})
.or_else(|| {
non_empty_env("HARN_LLM_MODEL").map(|model| {
let resolved = harn_vm::llm_config::resolve_model_info(&model);
resolved.provider
})
})
.unwrap_or_else(harn_vm::llm_config::default_provider);
let raw_model = non_empty_env("HARN_LLM_MODEL").or_else(|| {
if provider == "local" {
non_empty_env("LOCAL_LLM_MODEL")
} else {
None
}
});
let model = raw_model
.map(|model| harn_vm::llm_config::resolve_model(&model).0)
.map_or_else(
|| harn_vm::llm_config::default_model_for_provider(&provider),
Ok,
)?;
Ok((provider, model))
}
pub(super) fn acp_prompt_capabilities() -> serde_json::Value {
let capabilities = configured_llm_route_for_capabilities()
.map(|(provider, model)| harn_vm::llm::capabilities::lookup(&provider, &model))
.unwrap_or_default();
serde_json::json!({
"image": capabilities.vision || capabilities.vision_supported,
"audio": capabilities.audio,
"embeddedContext": capabilities.pdf || capabilities.files_api_supported,
})
}
pub(super) fn acp_agent_capabilities() -> serde_json::Value {
serde_json::json!({
"_meta": harn_acp_extension_meta(),
"loadSession": true,
"session": {
"inject": {
"modes": ["queue", "steer", "interrupt_immediate"],
"pending": {
"replace": true,
},
},
"injectHostEvent": {
"kinds": ["host_tool_result", "host_attachment"],
"delivery": ["turn_boundary", "immediate", "after_next_tool_call"],
},
"remind": {
"modes": ["interrupt_immediate", "finish_step", "audit_only"],
"pending": {
"list": true,
"revoke": true,
},
},
},
"promptCapabilities": acp_prompt_capabilities(),
"mcpCapabilities": {
"http": true,
"sse": true,
},
"sessionCapabilities": {
"close": {},
"list": {},
"resume": {},
"rollback": {},
"redo": {},
"restoreToolCall": {},
"cancelToolCall": {},
},
})
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct NormalizedAcpPrompt {
pub(super) text: String,
pub(super) content: Vec<serde_json::Value>,
pub(super) messages: Vec<serde_json::Value>,
pub(super) correlation: super::AcpPromptCorrelation,
}
pub(super) fn normalize_acp_prompt(
params: &serde_json::Value,
) -> Result<NormalizedAcpPrompt, String> {
let correlation: super::AcpPromptCorrelation = serde_json::from_value(params.clone())
.map_err(|error| format!("session/prompt: invalid caller message identity: {error}"))?;
let Some(prompt) = params.get("prompt") else {
return Ok(NormalizedAcpPrompt {
text: String::new(),
content: Vec::new(),
messages: prompt_messages_for_content(&[], &correlation),
correlation,
});
};
let blocks = prompt.as_array().ok_or_else(|| {
"session/prompt: prompt must be an array of ACP content blocks".to_string()
})?;
let mut content = Vec::new();
for block in blocks {
content.push(normalize_acp_prompt_block(block)?);
}
let text = prompt_text_from_content(&content);
let messages = prompt_messages_for_content(&content, &correlation);
Ok(NormalizedAcpPrompt {
text,
content,
messages,
correlation,
})
}
pub(super) fn normalize_acp_prompt_block(
block: &serde_json::Value,
) -> Result<serde_json::Value, String> {
match block.get("type").and_then(|value| value.as_str()) {
Some("text") => Ok(serde_json::json!({
"type": "text",
"text": required_string(block, "text", "text prompt block")?,
})),
Some("image") => normalize_binary_prompt_block(block, "image"),
Some("audio") => normalize_binary_prompt_block(block, "audio"),
Some("resource") => {
let mut normalized = normalize_embedded_resource_block(block)?;
normalized["_harn"] = serde_json::json!({"source_content": block});
Ok(normalized)
}
Some("resource_link") => normalize_resource_link_block(block),
Some(other) => Err(format!(
"session/prompt: unsupported content block type `{other}`"
)),
None => Err("session/prompt: content block is missing required `type`".to_string()),
}
}
pub(super) fn normalize_binary_prompt_block(
block: &serde_json::Value,
block_type: &str,
) -> Result<serde_json::Value, String> {
let media_type = required_media_type(block, block_type)?;
let data = block
.get("data")
.or_else(|| block.get("base64"))
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty());
let uri = block
.get("uri")
.or_else(|| block.get("url"))
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty());
let mut normalized = serde_json::json!({
"type": block_type,
"media_type": media_type,
});
if let Some(data) = data {
normalized["base64"] = serde_json::json!(data);
if let Some(uri) = uri {
normalized["source_uri"] = serde_json::json!(uri);
}
} else if let Some(uri) = uri {
normalized["url"] = serde_json::json!(uri);
} else {
return Err(format!(
"session/prompt: {block_type} block requires `data` or `uri`"
));
}
if block_type == "image" {
if let Some(detail) = block.get("detail").and_then(|value| value.as_str()) {
normalized["detail"] = serde_json::json!(detail);
}
}
Ok(normalized)
}
pub(super) fn normalize_embedded_resource_block(
block: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let resource = block
.get("resource")
.and_then(|value| value.as_object())
.ok_or_else(|| "session/prompt: resource block requires `resource` object".to_string())?;
let uri = resource
.get("uri")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| "session/prompt: embedded resource requires `uri`".to_string())?;
let media_type = resource
.get("mimeType")
.or_else(|| resource.get("media_type"))
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty());
if let Some(text) = resource.get("text").and_then(|value| value.as_str()) {
return Ok(serde_json::json!({
"type": "text",
"text": render_embedded_text_resource(uri, media_type, text),
"uri": uri,
"media_type": media_type,
}));
}
let blob = resource
.get("blob")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| "session/prompt: embedded resource requires `text` or `blob`".to_string())?;
let Some(media_type) = media_type else {
return Ok(serde_json::json!({
"type": "text",
"text": format!("Embedded binary resource: {uri}\nMIME type: unknown"),
"uri": uri,
}));
};
if media_type.starts_with("image/") {
Ok(serde_json::json!({
"type": "image",
"base64": blob,
"media_type": media_type,
"source_uri": uri,
}))
} else if media_type.starts_with("audio/") {
Ok(serde_json::json!({
"type": "audio",
"base64": blob,
"media_type": media_type,
"source_uri": uri,
}))
} else if media_type == "application/pdf" {
Ok(serde_json::json!({
"type": "pdf",
"base64": blob,
"media_type": media_type,
"source_uri": uri,
}))
} else {
Ok(serde_json::json!({
"type": "text",
"text": format!("Embedded binary resource: {uri}\nMIME type: {media_type}"),
"uri": uri,
"media_type": media_type,
}))
}
}
pub(super) fn normalize_resource_link_block(
block: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let uri = required_string(block, "uri", "resource_link prompt block")?;
let mut lines = vec![format!("Resource link: {uri}")];
for key in ["name", "title", "description", "mimeType", "media_type"] {
if let Some(value) = block.get(key).and_then(|value| value.as_str()) {
if !value.is_empty() {
lines.push(format!("{key}: {value}"));
}
}
}
if let Some(size) = block.get("size").and_then(|value| value.as_u64()) {
lines.push(format!("size: {size}"));
}
Ok(serde_json::json!({
"type": "text",
"text": lines.join("\n"),
"uri": uri,
}))
}
pub(super) fn render_embedded_text_resource(
uri: &str,
media_type: Option<&str>,
text: &str,
) -> String {
let mut rendered = format!("Embedded resource: {uri}");
if let Some(media_type) = media_type {
rendered.push_str(&format!("\nMIME type: {media_type}"));
}
rendered.push_str("\n\n");
rendered.push_str(text);
rendered
}
pub(super) fn required_media_type(
block: &serde_json::Value,
block_type: &str,
) -> Result<String, String> {
block
.get("mimeType")
.or_else(|| block.get("media_type"))
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| format!("session/prompt: {block_type} block requires `mimeType`"))
}
pub(super) fn required_string(
value: &serde_json::Value,
key: &str,
context: &str,
) -> Result<String, String> {
value
.get(key)
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| format!("session/prompt: {context} requires `{key}`"))
}
pub(super) fn retarget_prompt_text(prompt: &mut NormalizedAcpPrompt, text: String) {
if let Some(block) = prompt
.content
.iter_mut()
.find(|block| block.get("type").and_then(|value| value.as_str()) == Some("text"))
{
block["text"] = serde_json::json!(text);
} else {
prompt.content.insert(
0,
serde_json::json!({
"type": "text",
"text": text,
}),
);
}
prompt.text = prompt_text_from_content(&prompt.content);
prompt.messages = prompt_messages_for_content(&prompt.content, &prompt.correlation);
}
pub(super) fn prompt_text_from_content(content: &[serde_json::Value]) -> String {
content
.iter()
.filter_map(|block| {
if block.get("type").and_then(|value| value.as_str()) == Some("text") {
block.get("text").and_then(|value| value.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn prompt_messages_for_content(
content: &[serde_json::Value],
correlation: &super::AcpPromptCorrelation,
) -> Vec<serde_json::Value> {
let message_content = if content.is_empty() {
serde_json::Value::String(String::new())
} else {
serde_json::Value::Array(content.to_vec())
};
let mut message = serde_json::json!({
"role": "user",
"content": message_content,
});
if let Some(message_id) = &correlation.message_id {
message["messageId"] = serde_json::json!(message_id);
}
vec![message]
}
#[cfg(test)]
mod tests {
#[tokio::test(flavor = "current_thread")]
async fn prompt_resource_source_survives_canonical_persistence() {
use harn_session_store::{AppendEvent, CreateSession, SessionEventKind, SessionStore};
use harn_vm::session_timeline::{query_session_store_timeline, SessionTimelineQuery};
let root = tempfile::tempdir().unwrap();
let store = harn_vm::open_canonical_store(root.path()).unwrap();
store
.create(CreateSession {
id: Some("resource-source".into()),
..Default::default()
})
.await
.unwrap();
let resource = serde_json::json!({
"type": "resource",
"resource": {
"uri": "burin-attachment://draft/target",
"mimeType": "application/vnd.burin.sketch+json",
"text": "{\n\"label\": \"MIME type: inside the drawing\"\n}",
},
});
let prompt = super::normalize_acp_prompt(&serde_json::json!({
"messageId": "authored-turn",
"prompt": [resource.clone(), {"type": "text", "text": "Preserve this through reopen."}],
}))
.unwrap();
assert!(prompt.content[0]["text"]
.as_str()
.unwrap()
.starts_with("Embedded resource:"));
assert_eq!(prompt.content[0]["_harn"]["source_content"], resource);
store
.append(
"resource-source",
AppendEvent::new(
SessionEventKind::Message,
serde_json::json!({"raw_message": prompt.messages[0]}),
),
)
.await
.unwrap();
let timeline = query_session_store_timeline(
&store,
SessionTimelineQuery::for_session("resource-source"),
)
.await
.unwrap()
.unwrap();
assert_eq!(
timeline.nodes.len(),
1,
"one admitted message, not a shadow resource event"
);
let restored =
&timeline.nodes[0].attributes["raw_message"]["content"][0]["_harn"]["source_content"];
assert_eq!(restored, &resource);
let replay =
super::normalize_acp_prompt(&serde_json::json!({"prompt": [restored]})).unwrap();
assert_eq!(
replay.content[0], prompt.content[0],
"replay never parses or rewraps rendered prose"
);
let plain = super::normalize_acp_prompt_block(&serde_json::json!({
"type": "text", "text": "Embedded resource: authored plain text",
}))
.unwrap();
assert!(
plain.get("_harn").is_none(),
"similar prose cannot acquire resource identity"
);
assert!(
super::normalize_acp_prompt_block(&serde_json::json!({
"type": "resource", "resource": {"text": "missing URI"},
}))
.is_err(),
"preserving source never bypasses resource validation"
);
}
#[test]
fn caller_identity_survives_multimodal_prompt_retargeting() {
let image = serde_json::json!({"type": "image", "mimeType": "image/png", "data": "AAAA"});
let normalized_image =
serde_json::json!({"type": "image", "media_type": "image/png", "base64": "AAAA"});
let mut prompt = super::normalize_acp_prompt(&serde_json::json!({
"messageId": "opaque caller identity",
"prompt": [{"type": "text", "text": "same request"}, image],
}))
.unwrap();
assert_eq!(prompt.messages[0]["messageId"], "opaque caller identity");
assert_eq!(prompt.messages[0]["content"][1], normalized_image);
super::retarget_prompt_text(&mut prompt, "rewritten request".into());
assert_eq!(prompt.messages[0]["messageId"], "opaque caller identity");
assert_eq!(prompt.messages[0]["content"][1], normalized_image);
assert_eq!(prompt.text, "rewritten request");
}
#[test]
fn absent_caller_identity_is_not_inferred_and_malformed_identity_refuses() {
let absent = super::normalize_acp_prompt(&serde_json::json!({
"prompt": [{"type": "text", "text": "same request"}],
}))
.unwrap();
assert!(absent.messages[0].get("messageId").is_none());
for identity in [
serde_json::Value::Null,
serde_json::json!(42),
serde_json::json!({}),
serde_json::json!(""),
serde_json::json!(" "),
] {
assert!(super::normalize_acp_prompt(&serde_json::json!({
"messageId": identity,
"prompt": [],
}))
.is_err());
}
}
#[test]
fn initialize_meta_carries_the_hosts_sandbox_confinement() {
let meta = super::harn_acp_extension_meta();
let confinement = &meta["harn"]["sandboxConfinement"];
assert_eq!(
confinement,
&harn_vm::llm::vm_value_to_json(&harn_vm::process_sandbox::host_confinement())
);
assert_eq!(
confinement["schema"],
harn_vm::process_sandbox::SANDBOX_CONFINEMENT_SCHEMA
);
let confines = confinement["confines_processes"]
.as_bool()
.expect("confines_processes is a boolean");
assert_eq!(confinement["os_hardened_refusal"].is_null(), confines);
}
}