Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
377 lines
16 KiB
Rust
377 lines
16 KiB
Rust
//! Per-agent first-user-message rendering.
|
|
//!
|
|
//! Mirrors `prompt::context::PromptContext` but for the first user message
|
|
//! (the prefix that contains `<user_info>`, `<git_status>`, optional
|
|
//! workspace overview, optional rules / skills / MCP listings).
|
|
//!
|
|
//! `UserMessageTemplate` selects the rendering strategy:
|
|
//! - `Default` -- the legacy Kigi prefix (built by the shell layer).
|
|
//! - `Custom` -- caller-supplied template string (MiniJinja, same delimiters
|
|
//! as the system prompt templates).
|
|
//!
|
|
//! The shell layer gathers session-scoped inputs (cwd, vcs status, rule
|
|
//! files, skill registry, MCP servers) and hands them to
|
|
//! `UserMessageContext::render`, which dispatches on `template`.
|
|
use crate::prompt::agents_md::AgentConfigFile;
|
|
use chrono::NaiveDate;
|
|
use kigi_tools::bridge::ToolBridge;
|
|
use kigi_tools::implementations::skills::types::SkillInfo;
|
|
use kigi_tools::types::skill_discovery_tracker::{XmlRenderMode, format_announcement_xml};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashSet;
|
|
use std::path::PathBuf;
|
|
/// Date format for the `Today's date` field of the user-message preamble
|
|
/// (e.g. "Friday Apr 24, 2026"). Any format change is observable to the model.
|
|
pub const USER_MESSAGE_DATE_FORMAT: &str = "%A %b %-d, %Y";
|
|
/// Per-repo character cap applied to `vcs_status` at render time. The
|
|
/// `<git_status>` block has no token budget -- this character cap is the only
|
|
/// size control, and it is applied per repo at render, never at gather, so
|
|
/// other consumers of the raw status are unaffected.
|
|
pub const GIT_STATUS_CHARACTER_LIMIT: usize = 10_000;
|
|
/// Trim, drop-if-empty, and cap a VCS status string for the
|
|
/// `<git_status>` block.
|
|
///
|
|
/// Returns `None` when the trimmed status is empty (so the section is dropped
|
|
/// and no empty code fence is emitted), otherwise the status capped at
|
|
/// [`GIT_STATUS_CHARACTER_LIMIT`] -- snapped back to the last newline -- with
|
|
/// the `... (git status truncated)` marker appended.
|
|
fn normalize_git_status(status: &str) -> Option<String> {
|
|
let status = status.trim();
|
|
if status.is_empty() {
|
|
return None;
|
|
}
|
|
if status.len() <= GIT_STATUS_CHARACTER_LIMIT {
|
|
return Some(status.to_string());
|
|
}
|
|
let mut end = GIT_STATUS_CHARACTER_LIMIT;
|
|
while !status.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
let mut truncated = &status[..end];
|
|
if let Some(nl) = truncated.rfind('\n')
|
|
&& nl > 0
|
|
{
|
|
truncated = &truncated[..nl];
|
|
}
|
|
Some(format!("{truncated}\n\n... (git status truncated)"))
|
|
}
|
|
/// Selects the first-user-message rendering strategy for an agent.
|
|
///
|
|
/// Built-in variants decrypt the underlying XOR-obfuscated template on demand
|
|
/// (obfuscation, not security). Decrypted bytes are zeroed on drop via
|
|
/// `Zeroizing`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum UserMessageTemplate {
|
|
/// Legacy Kigi prefix: `<user_info>` + optional `<git_status>`.
|
|
/// Built directly by the shell layer; this
|
|
/// renderer returns `None` for `Default` and the caller falls back to
|
|
/// its own legacy path.
|
|
#[default]
|
|
Default,
|
|
/// Caller-supplied MiniJinja template string.
|
|
Custom(String),
|
|
}
|
|
impl UserMessageTemplate {
|
|
pub fn is_cursor(&self) -> bool {
|
|
false
|
|
}
|
|
}
|
|
/// Backward-compatible deserialization: accepts both the new tagged format
|
|
/// (`"default"`, `{"custom": "..."}`) and a bare string (treated
|
|
/// as `Custom`).
|
|
impl<'de> Deserialize<'de> for UserMessageTemplate {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
struct Visitor;
|
|
impl<'de> serde::de::Visitor<'de> for Visitor {
|
|
type Value = UserMessageTemplate;
|
|
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
f.write_str(r#""default", "cursor", {"custom": "..."}, or a template string"#)
|
|
}
|
|
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
|
match v {
|
|
"default" => Ok(UserMessageTemplate::Default),
|
|
other => Ok(UserMessageTemplate::Custom(other.to_owned())),
|
|
}
|
|
}
|
|
fn visit_map<M: serde::de::MapAccess<'de>>(
|
|
self,
|
|
mut map: M,
|
|
) -> Result<Self::Value, M::Error> {
|
|
match map.next_key::<String>()? {
|
|
Some(ref k) if k == "custom" => {
|
|
let val: String = map.next_value()?;
|
|
Ok(UserMessageTemplate::Custom(val))
|
|
}
|
|
Some(other) => Err(serde::de::Error::unknown_field(&other, &["custom"])),
|
|
None => Err(serde::de::Error::custom(r#"expected {"custom": "..."}"#)),
|
|
}
|
|
}
|
|
}
|
|
deserializer.deserialize_any(Visitor)
|
|
}
|
|
}
|
|
/// One discovered rule file (AGENTS.md / Claude.md / .kigi/rules/*.md).
|
|
///
|
|
/// Wire-compatible with `AgentConfigFile` -- this type exists so the
|
|
/// `UserMessageContext` does not depend on the AGENTS-discovery internals
|
|
/// beyond the path/content pair.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RuleEntry {
|
|
/// Absolute path of the file (used as the rule `name` attribute).
|
|
pub path: String,
|
|
/// Raw file body.
|
|
pub content: String,
|
|
}
|
|
impl From<AgentConfigFile> for RuleEntry {
|
|
fn from(f: AgentConfigFile) -> Self {
|
|
Self {
|
|
path: f.file_path,
|
|
content: f.content,
|
|
}
|
|
}
|
|
}
|
|
/// Connected MCP server metadata.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct McpServerEntry {
|
|
pub name: String,
|
|
/// Free-form usage instructions a user provided when configuring the
|
|
/// server. Surfaced in the `serverUseInstructions` attribute.
|
|
pub server_use_instructions: Option<String>,
|
|
/// Absolute path to the per-server descriptor folder. Surfaced in
|
|
/// the `folderPath` attribute. Compatible models read tool
|
|
/// schemas from `<folder_path>/tools/<tool>.json` and resource
|
|
/// descriptors from `<folder_path>/resources/<resource>.json` before
|
|
/// calling `CallMcpTool`/`FetchMcpResource`. The session is
|
|
/// responsible for materializing the descriptor files at this path.
|
|
pub folder_path: Option<String>,
|
|
}
|
|
/// All inputs the templated first user message needs. The shell gathers
|
|
/// these once at session start (and again on compaction) and hands the
|
|
/// struct to `render`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct UserMessageContext {
|
|
pub template: UserMessageTemplate,
|
|
/// Display path -- the path the model sees as the workspace.
|
|
pub workspace_path: PathBuf,
|
|
/// OS identifier surfaced as the `<user_info>` `OS Version:` value.
|
|
///
|
|
/// This is `"<kernel> <release>"` (e.g. `"darwin 24.6.0"`,
|
|
/// `"linux 6.5.0-..."`) -- not the OS family (`std::env::consts::OS`, e.g.
|
|
/// `"macos"`). Producers that don't have a uname-style string available may
|
|
/// pass `std::env::consts::OS` as a fallback; callers that need the full
|
|
/// string should use `kigi_shell::util::uname::os_kernel_and_release`
|
|
/// (or equivalent).
|
|
pub os_family: String,
|
|
/// `$SHELL` env, basename only -- e.g. "zsh", "bash".
|
|
pub shell: String,
|
|
/// Git/jj working-tree root, if any.
|
|
pub vcs_root: Option<PathBuf>,
|
|
/// Pre-fetched VCS status output (caller handles timeouts).
|
|
pub vcs_status: Option<String>,
|
|
/// Local date captured at session start (or compaction). Formatted
|
|
/// inside the renderer using [`USER_MESSAGE_DATE_FORMAT`] so the producer
|
|
/// cannot accidentally drift the model-facing date shape.
|
|
pub today_local: Option<NaiveDate>,
|
|
/// Per-workspace terminals folder, surfaced as
|
|
/// `Terminals folder: <path>` in the `<user_info>` block. The
|
|
/// shell tool persists each background command's output to a file
|
|
/// here (`<terminals_folder>/<numeric-shell-id>.txt`); the model uses
|
|
/// this path to read terminal state via the read tool. Optional --
|
|
/// when `None`, the line is omitted from the rendered preamble.
|
|
pub terminals_folder: Option<PathBuf>,
|
|
/// Workspace-scoped rule files (cwd / repo root / optional workspace user dir).
|
|
pub workspace_rules: Vec<RuleEntry>,
|
|
/// User-scoped rule files (~/.kigi/, ~/.claude/).
|
|
pub user_rules: Vec<RuleEntry>,
|
|
/// Skill registry snapshot (already deduped). Rendered through the
|
|
/// shared budget-tier renderer.
|
|
pub skills: Vec<SkillInfo>,
|
|
/// Optional listing budget in characters; defaults to the standard
|
|
/// 1%-of-context heuristic when None.
|
|
pub skill_listing_budget_chars: Option<usize>,
|
|
/// Connected MCP servers (alphabetical).
|
|
pub mcp_servers: Vec<McpServerEntry>,
|
|
/// Absolute path to the per-workspace MCP descriptor root
|
|
/// (`~/.kigi/projects/<encoded-cwd>/mcps`). Surfaced in
|
|
/// the `<mcp_file_system>` instructions so the model knows where
|
|
/// to discover tool/resource schemas. Required when `mcp_servers` is
|
|
/// non-empty; ignored otherwise.
|
|
pub mcps_root: Option<String>,
|
|
/// Client-facing name of the read tool (resolved from `TemplateRenderer`).
|
|
/// Used in the skill section's instructional text. Defaults to `"Read"`.
|
|
pub read_tool_name: String,
|
|
}
|
|
/// Typed placeholder bag handed to MiniJinja.
|
|
///
|
|
/// Field names here must match `${{ … }}` references in any caller-supplied
|
|
/// `Custom` template. Keeping this as a typed
|
|
/// struct -- rather than a free-form `serde_json::Value` -- means the set
|
|
/// of supported placeholders is greppable from one place, every nested
|
|
/// shape is enforced by `Serialize`, and rename refactors flow through
|
|
/// the compiler instead of silently producing empty strings at render
|
|
/// time.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct UserMessagePlaceholders<'a> {
|
|
workspace_path: String,
|
|
os_family: &'a str,
|
|
shell: &'a str,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
vcs_root: Option<String>,
|
|
/// Owned because the renderer caps/normalizes the raw status via
|
|
/// [`normalize_git_status`] before handing it to MiniJinja.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
vcs_status: Option<String>,
|
|
/// Pre-formatted using [`USER_MESSAGE_DATE_FORMAT`]; `None` is rendered as
|
|
/// `null` so the `${% if today_local %}` guard in the template drops
|
|
/// the line entirely.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
today_local: Option<String>,
|
|
/// Pre-rendered as a string so the template can `${% if terminals_folder %}`-guard.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
terminals_folder: Option<String>,
|
|
has_rules: bool,
|
|
workspace_rules: &'a [RuleEntry],
|
|
user_rules: &'a [RuleEntry],
|
|
/// Pre-rendered budgeted `<agent_skill>` XML rows; the template
|
|
/// just substitutes this verbatim. See `render_skill_listing_xml` for
|
|
/// why the skill listing is special-cased.
|
|
skill_listing: String,
|
|
/// Client-facing name of the read tool, used in the skill section's
|
|
/// instructional text. Defaults to `"Read"`.
|
|
read_tool_name: String,
|
|
mcp_servers: &'a [McpServerEntry],
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
mcps_root: Option<&'a str>,
|
|
}
|
|
impl UserMessageContext {
|
|
/// Build placeholders for MiniJinja rendering.
|
|
fn placeholders(&self) -> UserMessagePlaceholders<'_> {
|
|
UserMessagePlaceholders {
|
|
workspace_path: self.workspace_path.to_string_lossy().into_owned(),
|
|
os_family: &self.os_family,
|
|
shell: &self.shell,
|
|
vcs_root: self
|
|
.vcs_root
|
|
.as_ref()
|
|
.map(|p| p.to_string_lossy().into_owned()),
|
|
vcs_status: self.vcs_status.as_deref().and_then(normalize_git_status),
|
|
today_local: self
|
|
.today_local
|
|
.map(|d| d.format(USER_MESSAGE_DATE_FORMAT).to_string()),
|
|
terminals_folder: self
|
|
.terminals_folder
|
|
.as_ref()
|
|
.map(|p| p.to_string_lossy().into_owned()),
|
|
has_rules: !self.workspace_rules.is_empty() || !self.user_rules.is_empty(),
|
|
workspace_rules: &self.workspace_rules,
|
|
user_rules: &self.user_rules,
|
|
skill_listing: self.render_skill_listing_xml().unwrap_or_default(),
|
|
read_tool_name: self.read_tool_name.clone(),
|
|
mcp_servers: &self.mcp_servers,
|
|
mcps_root: self.mcps_root.as_deref(),
|
|
}
|
|
}
|
|
/// Render the skill list as `<agent_skill>` XML rows.
|
|
pub fn render_skill_listing_xml(&self) -> Option<String> {
|
|
if self.skills.is_empty() {
|
|
return None;
|
|
}
|
|
let mode = if self.template.is_cursor() {
|
|
XmlRenderMode::Verbatim
|
|
} else {
|
|
XmlRenderMode::Budgeted {
|
|
budget_chars: self.skill_listing_budget_chars,
|
|
overflow_indicator: true,
|
|
}
|
|
};
|
|
let mut announced = HashSet::new();
|
|
format_announcement_xml(&self.skills, &mut announced, None, None, mode)
|
|
}
|
|
/// Render the first user message.
|
|
///
|
|
/// Returns `None` for `UserMessageTemplate::Default` -- the caller is
|
|
/// responsible for the legacy prefix path. `Custom` dispatches through
|
|
/// `ToolBridge::render_prompt` so MiniJinja
|
|
/// `${{ tools.by_kind.* }}` references resolve correctly.
|
|
pub async fn render(&self, bridge: &ToolBridge) -> Option<String> {
|
|
let placeholders = serde_json::to_value(self.placeholders())
|
|
.expect("UserMessagePlaceholders serializes infallibly");
|
|
let rendered = match &self.template {
|
|
UserMessageTemplate::Default => return None,
|
|
UserMessageTemplate::Custom(s) => bridge.render_prompt(s, &placeholders).await,
|
|
};
|
|
rendered.map(|s| s.trim_end().to_string())
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn template_override_deserialize_strings() {
|
|
let v: UserMessageTemplate = serde_json::from_str(r#""default""#).unwrap();
|
|
assert_eq!(v, UserMessageTemplate::Default);
|
|
let v: UserMessageTemplate = serde_json::from_str(r#""my custom""#).unwrap();
|
|
assert_eq!(v, UserMessageTemplate::Custom("my custom".into()));
|
|
}
|
|
#[test]
|
|
fn template_override_deserialize_custom_map() {
|
|
let v: UserMessageTemplate =
|
|
serde_json::from_str(r#"{"custom": "my template body"}"#).unwrap();
|
|
assert_eq!(v, UserMessageTemplate::Custom("my template body".into()));
|
|
}
|
|
#[test]
|
|
fn template_override_round_trip() {
|
|
for original in [
|
|
UserMessageTemplate::Default,
|
|
UserMessageTemplate::Custom("body".into()),
|
|
] {
|
|
let json = serde_json::to_string(&original).unwrap();
|
|
let loaded: UserMessageTemplate = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(original, loaded);
|
|
}
|
|
}
|
|
/// A status under the cap passes through `unchanged` (trim is a no-op for
|
|
/// real `git status --short --branch` output, which starts with `##`).
|
|
#[test]
|
|
fn normalize_git_status_passthrough_under_limit() {
|
|
let status = "## main...origin/main\n M src/app.rs";
|
|
assert_eq!(normalize_git_status(status).as_deref(), Some(status));
|
|
}
|
|
/// Empty / whitespace-only status -> `None` so the section is dropped and
|
|
/// no empty fence is emitted.
|
|
#[test]
|
|
fn normalize_git_status_drops_whitespace_only() {
|
|
assert_eq!(normalize_git_status(""), None);
|
|
assert_eq!(normalize_git_status(" \n\t "), None);
|
|
}
|
|
/// A status over the cap is truncated at the last newline before the limit
|
|
/// and carries the spec's truncation marker.
|
|
#[test]
|
|
fn normalize_git_status_truncates_over_limit() {
|
|
let mut status = String::from("## main...origin/main\n");
|
|
while status.len() <= GIT_STATUS_CHARACTER_LIMIT {
|
|
status.push_str(" M src/some/long/path/to/file.rs\n");
|
|
}
|
|
assert!(status.len() > GIT_STATUS_CHARACTER_LIMIT);
|
|
let out = normalize_git_status(&status).expect("non-empty status");
|
|
assert!(
|
|
out.ends_with("\n\n... (git status truncated)"),
|
|
"missing truncation marker: {out}"
|
|
);
|
|
let body = out
|
|
.strip_suffix("\n\n... (git status truncated)")
|
|
.expect("marker suffix");
|
|
assert!(
|
|
body.len() <= GIT_STATUS_CHARACTER_LIMIT,
|
|
"body {} exceeds cap {GIT_STATUS_CHARACTER_LIMIT}",
|
|
body.len()
|
|
);
|
|
assert!(status.starts_with(body), "body is not a clean prefix");
|
|
assert!(!body.ends_with('\n'), "body should be snapped to last line");
|
|
}
|
|
}
|