blob: 66307b3d0cc242d553a818d1a4da922b3534345e [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::*;
use futures_util::FutureExt;
use maka_agent_facts::input::InputReceipt;
use maka_capabilities::call::Attachments;
use maka_capabilities::call::Scope;
use maka_host_runtime::application::NativeServices;
use maka_skills_api::contract::{Preparation, PreparationRecord, Prepare, SkillToolRequest};
use std::{collections::BTreeSet, panic::AssertUnwindSafe};
const SOURCE: &str = "maka.agent.skills";
#[derive(Serialize)]
struct Selected {
import: String,
binding: String,
}
pub(super) struct Library {
selected: Selected,
services: NativeServices,
source: SourceRevision,
load: Option<String>,
search: Option<String>,
}
pub(super) enum Input {
Ready {
message: MessageInput,
required_tools: BTreeSet<String>,
},
Rejected {
reason: SkillInputRejection,
skills: Option<maka_skills::SkillInvocationResult>,
},
}
impl Library {
pub fn select(
context: &NativeContext,
settings: &Configuration,
mut source: SourceRevision,
) -> Result<Option<Self>, String> {
let Some(import) = &settings.skill_library else {
return Ok(None);
};
let services = context.services();
let bindings = services
.imports()
.find(|(name, _)| *name == import)
.map(|(_, bindings)| bindings)
.ok_or("agent_skill_import_unavailable")?;
let [binding] = bindings else {
return Err("agent_skill_import_ambiguous".into());
};
if binding.contract() != &maka_skills_api::contract() {
return Err("agent_skill_contract_invalid".into());
}
let name = |method: &str| {
settings
.tools
.iter()
.find(|tool| {
tool.input == ToolInput::Skill
&& tool.method == method
&& services.imports().any(|(name, targets)| {
name == tool.import
&& targets.len() == 1
&& targets[0].execution_basis() == binding.execution_basis()
})
})
.map(|tool| tool.name.clone())
};
let load = name("load");
let search = name("search");
let selected = Selected {
import: import.clone(),
binding: binding.execution_basis().into(),
};
source.kind = SourceKind::Input;
source.name = SOURCE.into();
Ok(Some(Self {
selected,
services,
source,
load,
search,
}))
}
}
impl Owner {
pub(crate) async fn skill_candidates(
&self,
input: SkillCandidatesRead,
parent: &Scope,
) -> Result<SkillCandidates, String> {
let session = self.session(&input.session_id).await?;
let Some(library) = &self.skills else {
return Ok(SkillCandidates::default());
};
let SkillCandidatesBackend::Model {
model,
thinking_level,
} = input.backend
else {
return Ok(SkillCandidates::default());
};
let editing = match crate::model::candidate_editing_tools(
&self.configuration,
&self.catalog,
model,
thinking_level,
)
.await
{
Ok(editing) => editing,
Err(_) => return Ok(SkillCandidates::default()),
};
workspace::validate(
&self.workspace_roots,
&session.configuration.workspace,
&self.managed_worktrees,
)
.await?;
// Only inspect graph selection: opening a new epoch belongs to send admission.
let graph = match &self.graphs {
Some(graphs) => graphs
.current(&input.session_id)
.await?
.filter(|control| control.selected)
.map(|control| control.epoch),
None => None,
};
// This read-only basis selects tool names; it never renders or admits model work.
let basis = MessageBasis {
language: Language::En,
compaction_prompt: None,
graph,
collaboration_mode: session.configuration.collaboration_mode,
background_kind: BackgroundKind::Schedule,
tool_discovery: false,
allowed_tools: session.configuration.tools.clone(),
background: None,
internal_tools: vec![],
internal_bindings: BTreeMap::new(),
behavior: None,
model: None,
executor: None,
workspace: session.configuration.workspace.clone(),
fingerprint: String::new(),
tools: vec![],
};
let input = serde_json::to_value(SkillToolRequest {
workspace: Some(session.configuration.workspace),
tools: self.selected_tools(&basis, false, Some(editing)).names(),
input: maka_skills_api::contract::ModelSearch {
query: input.query,
limit: Some(8),
},
})
.map_err(|_| "agent_skill_query_invalid")?;
let import = library.selected.import.clone();
let mut ticket = parent
.resources
.reserve()
.map_err(|_| "agent_skill_call_closed")?;
let request = library
.services
.request(parent.identity.clone(), parent.cancellation.child_token())?;
let result = self
.workers
.spawn(async move {
ticket.start();
let result = AssertUnwindSafe(async {
request
.call(&import, 0, "search".into(), input, Attachments::default())?
.await
})
.catch_unwind()
.await;
let cleanup = request.finish().await;
ticket.complete(cleanup.clone());
cleanup?;
result.map_err(|_| "agent_skill_query_panicked".to_owned())
})
.await
.map_err(|_| "agent_skill_query_lost")???;
if parent.cancellation.is_cancelled() {
return Err("agent_skill_call_cancelled".into());
}
candidates(result)
}
pub(super) async fn prepare_skills(
&self,
mut message: MessageInput,
language: Language,
workspace: Option<crate::contract::Workspace>,
parent: &Scope,
tools: Vec<String>,
) -> Result<Input, String> {
if parent.cancellation.is_cancelled() {
return Err("agent_skill_call_cancelled".into());
}
let reject = |reason, skills| Ok(Input::Rejected { reason, skills });
let Some(library) = &self.skills else {
let discovery = Default::default();
let preferences = maka_skills::Preferences::Available(Default::default());
let host = Default::default();
let catalog = maka_skills::Catalog {
discovery: &discovery,
preferences: &preferences,
host: &host,
};
return match catalog.prepare_invocation(&message.text, &[], language) {
maka_skills::PreparedInvocation::Blocked(invocation) => {
reject(SkillInputRejection::SkillsUnavailable, Some(invocation))
}
maka_skills::PreparedInvocation::Passthrough => Ok(Input::Ready {
message,
required_tools: Default::default(),
}),
maka_skills::PreparedInvocation::Ready { .. } => {
Err("agent_skill_preparation_invalid".into())
}
};
};
let input = serde_json::to_value(SkillToolRequest {
workspace,
tools: tools.clone(),
input: Prepare {
language,
message: message.text.clone(),
load_tool: library
.load
.as_ref()
.filter(|name| tools.contains(name))
.cloned(),
search_tool: library
.search
.as_ref()
.filter(|name| tools.contains(name))
.cloned(),
},
})
.map_err(|_| "agent_skill_preparation_invalid")?;
let import = library.selected.import.clone();
let mut ticket = parent
.resources
.reserve()
.map_err(|_| "agent_skill_call_closed")?;
let request = library
.services
.request(parent.identity.clone(), parent.cancellation.child_token())?;
let result = self
.workers
.spawn(async move {
ticket.start();
let result = AssertUnwindSafe(async {
request
.call(&import, 0, "prepare".into(), input, Attachments::default())?
.await
})
.catch_unwind()
.await;
let cleanup = request.finish().await;
ticket.complete(cleanup.clone());
cleanup?;
result.map_err(|_| "agent_skill_preparation_panicked".to_owned())
})
.await
.map_err(|_| "agent_skill_preparation_lost")??;
if parent.cancellation.is_cancelled() {
return Err("agent_skill_call_cancelled".into());
}
let result = match result {
Ok(value) => serde_json::from_value::<Preparation>(value)
.map_err(|_| "agent_skill_preparation_invalid")?,
Err(_) => return reject(SkillInputRejection::SkillsUnavailable, None),
};
let (text, record, required_tools) = match result {
Preparation::Ready {
text,
record,
required_tools,
} => (text, record, required_tools),
Preparation::Blocked { invocation } => {
return reject(SkillInputRejection::SkillsUnavailable, Some(invocation));
}
};
record
.invocation
.validate()
.map_err(|_| "agent_skill_preparation_invalid")?;
if !maka_agent_facts::archive::valid_projection_digest(&record.revision)
|| required_tools.iter().any(|tool| !tools.contains(tool))
{
return Err("agent_skill_preparation_invalid".into());
}
if let Some(text) = text {
message.display_text = Some(message.text.clone());
let references =
maka_skills::inline_references(&record.invocation.receipts, &message.text);
message.inline_references = (!references.is_empty()).then_some(references);
message.text = text;
}
let mut source = library.source.clone();
source.revision = maka_agent_facts::artifact::content_digest(
&serde_json::to_vec(&(&library.selected, &record))
.map_err(|_| "agent_skill_preparation_invalid")?,
);
message.preparation.push(InputReceipt {
source,
receipt: serde_json::to_value(record).map_err(|_| "agent_skill_preparation_invalid")?,
});
maka_agent_facts::input::validate_receipts(&message.preparation)
.map_err(|_| "agent_skill_preparation_invalid")?;
Ok(Input::Ready {
message,
required_tools,
})
}
}
pub(super) fn prompt(message: &MessageInput) -> Result<Option<SystemPrompt>, String> {
let mut receipts = message
.preparation
.iter()
.filter(|receipt| receipt.source.name == SOURCE);
let Some(receipt) = receipts.next() else {
return Ok(None);
};
if receipts.next().is_some() {
return Err("agent_skill_preparation_invalid".into());
}
let record: PreparationRecord = serde_json::from_value(receipt.receipt.clone())
.map_err(|_| "agent_skill_preparation_invalid")?;
if record.metadata_prompt.is_empty() {
return Ok(None);
}
let prompt = SystemPrompt {
text: record.metadata_prompt,
policy_revision: 0,
sources: vec![receipt.source.clone()],
};
prompt
.validate()
.map_err(|_| "agent_skill_preparation_invalid")?;
Ok(Some(prompt))
}
// Deserialize only the metadata we expose; Library.search owns visibility and precedence.
fn candidates(value: serde_json::Value) -> Result<SkillCandidates, String> {
#[derive(Deserialize)]
struct Page {
revision: String,
matches: Vec<Entry>,
}
#[derive(Deserialize)]
struct Entry {
#[serde(rename = "ref")]
reference: String,
id: String,
name: String,
description: String,
}
let page: Page = serde_json::from_value(value).map_err(|_| "agent_skill_query_invalid")?;
if !maka_agent_facts::archive::valid_projection_digest(&page.revision) || page.matches.len() > 8
{
return Err("agent_skill_query_invalid".into());
}
Ok(SkillCandidates {
revision: Some(page.revision),
skills: page
.matches
.into_iter()
.filter(|entry| {
!entry.id.is_empty()
&& entry
.id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))
})
.map(|entry| SkillCandidate {
invocation: format!("/skill:{}", entry.id),
reference: entry.reference,
name: entry.name,
description: entry.description,
})
.collect(),
})
}