Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "kit"
version = "0.1.90"
version = "0.1.91"
edition = "2024"
rust-version = "1.94.0"
publish = false
Expand All @@ -23,7 +23,7 @@ agentkit-plugins = "=0.10.7"
agentkit-provider-openrouter = "=0.10.7"
agentkit-task-manager = "=0.10.6"
agentkit-tool-compose = { version = "=0.10.9", default-features = false, features = ["runlet"] }
agentkit-tool-skills = "=0.10.7"
agentkit-tool-skills = "=0.10.8"
agentkit-tools-core = "=0.10.5"
async-trait = "=0.1.92"
atomicwrites = "=0.4.4"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ scope that could safely support unattended approval.

## Agent Plugins

Kit loads validated Agent Plugin packages from local directories and SHA-256-pinned ZIP, tar.gz, or tar URLs. Plugin skills join the existing `activate_skill` catalog, and supported plugin MCP declarations work without an explicit MCP JSON file. Kit runs plugin `stdio` servers and connects `streamable-http` servers; deprecated `sse` entries are skipped with a stderr diagnostic. An explicit `--mcp-config` or `mcp_config` file overlays plugins by server name and remains live-reloadable. See [Agent Plugins](docs/user/agent-plugins.md) for placeholders, data directories, collisions, precedence, and security details.
Kit loads validated Agent Plugin packages from local directories and SHA-256-pinned ZIP, tar.gz, or tar URLs. Plugin skills join the existing `skill` catalog, and supported plugin MCP declarations work without an explicit MCP JSON file. Kit runs plugin `stdio` servers and connects `streamable-http` servers; deprecated `sse` entries are skipped with a stderr diagnostic. An explicit `--mcp-config` or `mcp_config` file overlays plugins by server name and remains live-reloadable. See [Agent Plugins](docs/user/agent-plugins.md) for placeholders, data directories, collisions, precedence, and security details.

## MCP

Expand Down
2 changes: 1 addition & 1 deletion docs/user/agent-plugins.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Agent Plugins

Kit can load Agent Plugin packages from a local directory or a checksum-pinned online archive. Source resolution happens at startup. Kit uses `agentkit-plugins` to validate the resolved package, exposes its valid Agent Skills through the existing `activate_skill` tool, and registers its supported MCP servers. A plugin-only configuration works without `--mcp-config` or `mcp_config`.
Kit can load Agent Plugin packages from a local directory or a checksum-pinned online archive. Source resolution happens at startup. Kit uses `agentkit-plugins` to validate the resolved package, exposes its valid Agent Skills through the existing `skill` tool, and registers its supported MCP servers. A plugin-only configuration works without `--mcp-config` or `mcp_config`.

## Configure a source

Expand Down
8 changes: 4 additions & 4 deletions docs/user/compose-and-local-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ return published

Calls lexically created inside the `after` block start only after `prepared` succeeds. If the prerequisite fails, dependent work does not run. Ordering one call does not make the whole program sequential; unrelated nodes may still overlap. Add explicit data dependencies or `after` edges around every required read-before-write or write-before-write relationship. In particular, do not launch concurrent edits of the same path or let a check race the command that creates its input.

## Activate Agent Skills
## Load Agent Skills

When valid skills exist under `<root>/.agents/skills` or `~/.agents/skills`, the hidden `activate_skill` tool lists their names and descriptions. If a task matches one, return the activation result through `compose` before proceeding so the instructions enter the model conversation:
When valid skills exist under `<root>/.agents/skills` or `~/.agents/skills`, the hidden `skill` tool lists their names and descriptions. If a task matches one, return the loaded skill through `compose` before proceeding so the instructions enter the model conversation:

```text
return activate_skill({ name: "review" })
return skill({ name: "review" })
```

Activation progressively discloses the skill's full `SKILL.md` body, directory, and resource paths. A hidden child result that is discarded by the Runlet is not separately added to the conversation, so do not call `activate_skill` without returning its value. The available-name schema is captured when the compose source is created; start a new session after changing the installed skill set.
Loading progressively discloses the skill's full `SKILL.md` body, directory, and resource paths. A hidden child result that is discarded by the Runlet is not separately added to the conversation, so do not call `skill` without returning its value. Skills can be loaded repeatedly. The available-name schema is captured when the compose source is created; start a new session after changing the installed skill set.

## Run commands with `shell`

Expand Down
2 changes: 1 addition & 1 deletion docs/user/getting-started-and-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ description: Review code changes for correctness.
Review the change and run the smallest relevant checks.
```

The `activate_skill` entry in `compose` initially discloses only valid skill names and descriptions. When a task matches, the agent activates the skill before proceeding; activation returns the full Markdown body, skill directory, and paths to supporting resources. Project and user skill files are read with the Kit process's normal host permissions. Invalid or unreadable skills are omitted, and repeated activation of the same skill is deduplicated for a session within the current Kit process.
The `skill` entry in `compose` initially discloses only valid skill names and descriptions. When a task matches, the agent loads the skill before proceeding; the result contains the full Markdown body, skill directory, and paths to supporting resources. Project and user skill files are read with the Kit process's normal host permissions. Invalid or unreadable skills are omitted, and the same skill can be loaded repeatedly.

The hidden-tool catalog is captured when Kit creates the session's compose source. Restart the session after adding or removing a skill so its advertised schema is refreshed.

Expand Down
82 changes: 2 additions & 80 deletions src/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,8 @@ use agentkit_loop::{
Agent, AgentEvent, LoopCtx, LoopError, LoopInterrupt, LoopMutator, LoopStep, ModelAdapter,
MutationPoint, SessionConfig, TelemetryConfig, TranscriptCursor,
};
use agentkit_tool_skills::SkillRegistry;
use async_trait::async_trait;
use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::Instant,
};
use std::{collections::HashMap, time::Instant};

use crate::{
events::{self, RuntimeEvent},
Expand Down Expand Up @@ -227,7 +222,6 @@ pub fn automatic<M>(
adapter: M,
telemetry: TelemetryConfig,
persistence: Option<SessionObserver>,
skills: Arc<SkillRegistry>,
session_id: impl Into<SessionId>,
) -> Result<AutomaticCompactor, String>
where
Expand All @@ -251,11 +245,7 @@ where
.with_strategy(SummarizeForContinuation::default()),
)
.with_backend(backend);
Ok(AutomaticCompactor {
inner,
persistence,
skills,
})
Ok(AutomaticCompactor { inner, persistence })
}

struct KitCompactionBackend<M> {
Expand Down Expand Up @@ -480,43 +470,9 @@ fn user_message_from_marker(mut marker: Item, part_index: usize, message: &str)
marker
}

fn removed_skill_instructions(before: &[Item], after: &[Item]) -> bool {
let activation_calls = before
.iter()
.flat_map(|item| &item.parts)
.filter_map(|part| match part {
Part::ToolCall(call) if call.name == "activate_skill" => Some(call.id.to_string()),
_ => None,
})
.collect::<HashSet<_>>();
if activation_calls.is_empty() {
return false;
}
let activation_outputs = |items: &[Item]| {
items
.iter()
.flat_map(|item| &item.parts)
.filter_map(|part| match part {
Part::ToolResult(result)
if activation_calls.contains(&result.call_id.to_string()) =>
{
Some((result.call_id.to_string(), result.output.clone()))
}
_ => None,
})
.collect::<HashMap<_, _>>()
};
let before_outputs = activation_outputs(before);
let after_outputs = activation_outputs(after);
before_outputs
.iter()
.any(|(id, output)| after_outputs.get(id) != Some(output))
}

pub struct AutomaticCompactor {
inner: StrategyCompactor,
persistence: Option<SessionObserver>,
skills: Arc<SkillRegistry>,
}

#[async_trait]
Expand Down Expand Up @@ -590,11 +546,6 @@ impl LoopMutator for AutomaticCompactor {
finish(false, false);
return Err(LoopError::Mutator(error));
}
// Reset only when model-facing skill instructions were actually removed,
// and only after durable replacement succeeds.
if removed_skill_instructions(cursor.as_slice(), &compacted) {
self.skills.reset_activations();
}
metadata.insert(
"replaced_items".into(),
(before.saturating_sub(compacted.len()) as u64).into(),
Expand Down Expand Up @@ -1036,35 +987,6 @@ mod tests {
);
}

#[test]
fn skill_reset_is_needed_only_when_activation_output_is_removed() {
let call = Item::new(
ItemKind::Assistant,
vec![Part::ToolCall(ToolCallPart::new(
"skill-call",
"activate_skill",
json!({"name": "simplify"}),
))],
);
let result = Item::new(
ItemKind::Tool,
vec![Part::ToolResult(ToolResultPart::success(
"skill-call",
ToolOutput::text("instructions".repeat(TOOL_OUTPUT_MAX_CHARS)),
))],
);
let before = vec![call.clone(), result.clone()];

assert!(!removed_skill_instructions(&before, &before));
assert!(removed_skill_instructions(
&before,
std::slice::from_ref(&call)
));

let truncated = vec![call, compact_tool_outputs(result)];
assert!(removed_skill_instructions(&before, &truncated));
}

#[test]
fn compact_recent_tool_output_is_bounded_and_drops_stale_usage() {
let item = Item::new(
Expand Down
5 changes: 1 addition & 4 deletions src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,7 @@ impl Runtime {
.register(Observed::new(AuthTool::new(self.mcp.clone())))
.register(Observed::new(McpTool::new(self.mcp.clone())));
let skill_tools = skills.tool_registry();
if let Some(skill_tool) = skill_tools.get(&ToolName::new("activate_skill")) {
if let Some(skill_tool) = skill_tools.get(&ToolName::new("skill")) {
children.register(observe_shared(skill_tool));
}
let child_specs = children.specs();
Expand Down Expand Up @@ -695,7 +695,6 @@ impl Runtime {
self.adapter.clone(),
self.agentkit_telemetry(),
Some(opened.observer.clone()),
Arc::clone(&skills),
format!("compaction-{}", crate::session::new_id()),
)
.map_err(|error| {
Expand Down Expand Up @@ -794,7 +793,6 @@ impl Runtime {
self.adapter.clone(),
self.agentkit_telemetry(),
None,
Arc::clone(&skills),
format!("compaction-{session}"),
)
.map_err(LoopError::InvalidState)?;
Expand Down Expand Up @@ -907,7 +905,6 @@ impl Runtime {
adapter.clone(),
self.agentkit_telemetry(),
Some(opened.observer.clone()),
Arc::clone(&skills),
format!("compaction-{}", crate::session::new_id()),
)
.map_err(AcpRuntimeError::Loop)?;
Expand Down
107 changes: 48 additions & 59 deletions src/runtime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ fn plugin_skills_join_the_catalog_without_broadening_discovery() {
let runtime = Runtime::new(root.path(), "gpt-5.4").unwrap();
let runtime = Runtime::with_plugin_skills(runtime, vec![plugin], vec![plugin_skill]).unwrap();
let skills = runtime.skills.tool_registry();
let tool = ToolSource::get(&skills, &ToolName::new("activate_skill")).unwrap();
let tool = ToolSource::get(&skills, &ToolName::new("skill")).unwrap();
let spec = tool.current_spec().unwrap();
let catalog = spec.input_schema.to_string();
assert!(catalog.contains("project-skill"));
Expand Down Expand Up @@ -365,7 +365,7 @@ fn plugin_skill_symlink_retargeting_fails_closed() {
symlink(replacement, skill.join("SKILL.md")).unwrap();

let skills = runtime.skills.tool_registry();
let tool = ToolSource::get(&skills, &ToolName::new("activate_skill")).unwrap();
let tool = ToolSource::get(&skills, &ToolName::new("skill")).unwrap();
let catalog = tool
.current_spec()
.map(|spec| spec.description)
Expand Down Expand Up @@ -396,14 +396,14 @@ fn project_skills_take_precedence_over_plugin_skills() {
Runtime::with_plugin_skills(runtime, vec![root.path().to_path_buf()], vec![plugin_skill])
.unwrap();
let skills = runtime.skills.tool_registry();
let tool = ToolSource::get(&skills, &ToolName::new("activate_skill")).unwrap();
let tool = ToolSource::get(&skills, &ToolName::new("skill")).unwrap();
let catalog = tool.current_spec().unwrap().description;
assert!(catalog.contains("Project version."));
assert!(!catalog.contains("Plugin version."));
}

#[tokio::test]
async fn session_skill_registries_reset_independently() {
async fn compose_can_load_a_skill_repeatedly() {
let root = tempfile::tempdir().unwrap();
write_skill(
&root.path().join(".agents/skills/reusable"),
Expand All @@ -412,70 +412,59 @@ async fn session_skill_registries_reset_independently() {
"full instructions",
);
let runtime = Runtime::new(root.path(), "gpt-5.4").unwrap();
let registry = runtime.fresh_skills();
let other_registry = runtime.fresh_skills();
let skills = registry.tool_registry();
let other_skills = other_registry.tool_registry();
let tool = ToolSource::get(&skills, &ToolName::new("activate_skill")).unwrap();
let other_tool = ToolSource::get(&other_skills, &ToolName::new("activate_skill")).unwrap();
let compose = runtime.compose(0);
let source: Arc<dyn ToolSource> = Arc::new(compose.compose.clone());
let executor: Arc<dyn ToolExecutor> = Arc::new(BasicToolExecutor::new([source]));
let permissions = Arc::new(AllowAllPermissions);
let resources: Arc<dyn agentkit_tools_core::ToolResources> = Arc::new(());
let session_id = SessionId::new("session");
let turn_id = TurnId::new("turn");
let owned = OwnedToolContext {
session_id: SessionId::new("session"),
turn_id: TurnId::new("turn"),
session_id: session_id.clone(),
turn_id: turn_id.clone(),
metadata: MetadataMap::new(),
permissions,
resources,
permissions: permissions.clone(),
resources: resources.clone(),
cancellation: None,
execution_scope: None,
execution_scope: Some(ToolExecutionScope {
executor,
session_id: session_id.clone(),
turn_id: turn_id.clone(),
permissions,
resources,
cancellation: None,
}),
approved_request: None,
};
let request = |call_id| {
ToolRequest::new(
ToolCallId::new(call_id),
ToolName::new("activate_skill"),
json!({ "name": "reusable" }),
SessionId::new("session"),
TurnId::new("turn"),
let outcome = compose
.backgroundable
.invoke_outcome(
ToolRequest::new(
ToolCallId::new("call"),
ToolName::new("compose"),
json!({
"script": "first = skill({ name: \"reusable\" })\nsecond = skill({ name: \"reusable\" })\nreturn [first, second]"
}),
session_id,
turn_id,
),
&mut owned.borrowed(),
)
};
let mut context = owned.borrowed();

let first = tool.invoke(request("first"), &mut context).await.unwrap();
assert!(
matches!(first.result.output, ToolOutput::Text(ref text) if text.contains("full instructions"))
);
let duplicate = tool
.invoke(request("duplicate"), &mut context)
.await
.unwrap();
let other_first = other_tool
.invoke(request("other-first"), &mut context)
.await
.unwrap();
assert!(
matches!(duplicate.result.output, ToolOutput::Text(ref text) if text == "Skill already read.")
);
assert!(
matches!(other_first.result.output, ToolOutput::Text(ref text) if text.contains("full instructions"))
);

registry.reset_activations();
.await;

let reactivated = tool
.invoke(request("reactivated"), &mut context)
.await
.unwrap();
assert!(
matches!(reactivated.result.output, ToolOutput::Text(ref text) if text.contains("full instructions"))
);
let other_duplicate = other_tool
.invoke(request("other-duplicate"), &mut context)
.await
.unwrap();
assert!(
matches!(other_duplicate.result.output, ToolOutput::Text(ref text) if text == "Skill already read.")
);
let ToolExecutionOutcome::Completed(result) = outcome else {
panic!("skill calls did not complete through compose: {outcome:?}");
};
let ToolOutput::Structured(loaded) = result.result.output else {
panic!("compose did not return structured output");
};
let loaded = loaded.as_array().expect("compose returned an array");
assert_eq!(loaded.len(), 2);
assert!(loaded.iter().all(|skill| {
skill
.as_str()
.is_some_and(|text| text.contains("full instructions"))
}));
}

#[test]
Expand Down