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",
"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",
"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",
"typed_checkpoint",
];
pub const HARN_TOOL_LIFECYCLE_EXTENSION_FIELDS: &[&str] = &[
"audit",
"changedPaths",
"data",
"durationMs",
"error",
"errorCategory",
"executionDurationMs",
"executor",
"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,
"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,
},
},
"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() -> (String, String) {
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)
.unwrap_or_else(|| harn_vm::llm_config::default_model_for_provider(&provider));
(provider, model)
}
pub(super) fn acp_prompt_capabilities() -> serde_json::Value {
let (provider, model) = configured_llm_route_for_capabilities();
let capabilities = harn_vm::llm::capabilities::lookup(&provider, &model);
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) fn normalize_acp_prompt(
params: &serde_json::Value,
) -> Result<NormalizedAcpPrompt, String> {
let Some(prompt) = params.get("prompt") else {
return Ok(NormalizedAcpPrompt {
text: String::new(),
content: Vec::new(),
messages: prompt_messages_for_content(&[]),
});
};
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);
Ok(NormalizedAcpPrompt {
text,
content,
messages,
})
}
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") => normalize_embedded_resource_block(block),
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);
}
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]) -> 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())
};
vec![serde_json::json!({
"role": "user",
"content": message_content,
})]
}