Skip to content
Open
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
15 changes: 15 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ members = [
"crates/jcode-desktop",
"crates/jcode-mempalace-adapter",
"crates/jcode-render-core",
"crates/jcode-keywords",
"evals/jbench",
]

Expand Down
1 change: 1 addition & 0 deletions crates/jcode-app-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ jcode-build-meta = { path = "../jcode-build-meta" }
# Re-exported via `pub use jcode_base::*` in lib.rs. default-features=false so
# this crate controls jcode-base's optional features (see [features] below).
jcode-base = { path = "../jcode-base", default-features = false }
jcode-keywords = { path = "../jcode-keywords" }
jcode-experiment-flags = { path = "../jcode-experiment-flags" }
jcode-compaction-core = { path = "../jcode-compaction-core" }
jcode-config-types = { path = "../jcode-config-types" }
Expand Down
93 changes: 93 additions & 0 deletions crates/jcode-app-core/src/agent/prompting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,105 @@ impl Agent {
.as_ref()
.map(std::path::PathBuf::from);

// Detect keywords, update mode state, execute workflows, build prompt.
// We skip the whole pipeline on empty input so an empty/system-only turn
// does not burn a turn of any active mode's budget.
let keyword_prompt = {
let latest_input = self
.session
.messages
.iter()
.rev()
.find(|m| matches!(m.role, crate::message::Role::User))
.and_then(|m| {
m.content.iter().find_map(|b| match b {
crate::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
})
.unwrap_or("");
if latest_input.is_empty() {
None
} else {
let detections = jcode_keywords::detect_keywords(latest_input);
let mut mode_state =
jcode_keywords::state::update_modes(&detections, working_dir.as_deref());

// Surface any mode conflicts (TDD + ultrawork, etc.) to logs.
let active_kinds: Vec<jcode_keywords::registry::WorkflowKind> =
mode_state.active_modes.iter().map(|m| m.workflow).collect();
for conflict in jcode_keywords::conflict::check_conflicts(&active_kinds) {
crate::logging::warn(&jcode_keywords::conflict::format_warning(&conflict));
}

// Process PREVIOUS turn's LLM response (phase transitions, completion)
// This runs at the START of the current turn, using last turn's response
if let Some(last_assistant) = self
.session
.messages
.iter()
.rev()
.find(|m| matches!(m.role, crate::message::Role::Assistant))
.and_then(|m| {
m.content.iter().find_map(|b| match b {
crate::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
})
{
let response_actions =
jcode_keywords::process_turn_response(&mode_state, last_assistant);
if !response_actions.is_empty() {
let _ = jcode_keywords::apply_actions(&mut mode_state, &response_actions);
}
}

// Classify task size so heavy workflows can suppress themselves for
// trivial requests (e.g. a one-line "$ultrawork fix typo").
let task_size = jcode_keywords::task_size::classify(latest_input);

// Execute active workflows for THIS turn
let actions = jcode_keywords::execute_active_workflows(
&mode_state,
latest_input,
working_dir.as_deref(),
&self.session.id,
task_size,
);
if !actions.is_empty() {
let (summaries, deferred) =
jcode_keywords::apply_actions(&mut mode_state, &actions);
for s in &summaries {
crate::logging::info(&format!("Keyword workflow: {}", s));
}
if !deferred.is_empty() {
crate::logging::warn(&format!(
"Keyword workflow: {} spawn action(s) deferred — they will not run until SubagentTool is wired from the agent runtime. (See issue #391 follow-up.)",
deferred.len()
));
}
}

// Persist metadata to disk
jcode_keywords::state::save_state(&mode_state, working_dir.as_deref());

// Build workflow prompt
let prompt = jcode_keywords::build_workflow_prompt(&mode_state);
if prompt.is_empty() {
None
} else {
Some(prompt)
}
}
};

let (mut split, _context_info) = crate::prompt::build_system_prompt_split(
skill_prompt.as_deref(),
&available_skills,
self.session.is_canary,
memory_prompt,
working_dir.as_deref(),
keyword_prompt,
);

self.append_current_turn_system_reminder(&mut split);
Expand Down
5 changes: 1 addition & 4 deletions crates/jcode-app-core/src/agent/turn_loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,7 @@ impl Agent {
max
));
if final_text.is_empty() {
final_text = format!(
"[agent stopped: reached max_turns limit of {}]",
max
);
final_text = format!("[agent stopped: reached max_turns limit of {}]", max);
}
break;
}
Expand Down
9 changes: 6 additions & 3 deletions crates/jcode-app-core/src/ambient/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,8 @@ impl AmbientRunnerHandle {
) -> anyhow::Result<()> {
let session = Session::load(session_id)?;
let cycle_provider = provider.fork();
let registry = tool::Registry::new(cycle_provider.clone(), tool::shared_agent_registry()).await;
let registry =
tool::Registry::new(cycle_provider.clone(), tool::shared_agent_registry()).await;
if session.is_canary {
registry.register_selfdev_tools().await;
registry.register_experimental_tools().await;
Expand Down Expand Up @@ -471,7 +472,8 @@ impl AmbientRunnerHandle {
let child_is_canary = child.is_canary;
let child_is_debug = child.is_debug;
let cycle_provider = provider.fork();
let registry = tool::Registry::new(cycle_provider.clone(), tool::shared_agent_registry()).await;
let registry =
tool::Registry::new(cycle_provider.clone(), tool::shared_agent_registry()).await;
if child_is_canary {
registry.register_selfdev_tools().await;
registry.register_experimental_tools().await;
Expand Down Expand Up @@ -930,7 +932,8 @@ impl AmbientRunnerHandle {
self.set_running_detail("setting up tools").await;

let cycle_provider = provider.fork();
let registry = tool::Registry::new(cycle_provider.clone(), tool::shared_agent_registry()).await;
let registry =
tool::Registry::new(cycle_provider.clone(), tool::shared_agent_registry()).await;
registry.register_ambient_tools().await;
// Issue #89: register MCP tools so user-installed MCP servers are
// available to the ambient agent — without this, the cycle agent
Expand Down
4 changes: 3 additions & 1 deletion crates/jcode-app-core/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,9 @@ impl Server {

let previous_status = session.status.clone();
let provider = self.provider.fork();
let registry = crate::tool::Registry::new(provider.clone(), crate::tool::shared_agent_registry()).await;
let registry =
crate::tool::Registry::new(provider.clone(), crate::tool::shared_agent_registry())
.await;
if session.is_canary {
registry.register_selfdev_tools().await;
registry.register_experimental_tools().await;
Expand Down
17 changes: 3 additions & 14 deletions crates/jcode-app-core/src/tool/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,7 @@ static SHARED_AGENT_REGISTRY: LazyLock<Option<Arc<jcode_agent_runtime::AgentRegi
let home = dirs::home_dir();
let cwd = std::env::current_dir().ok();
let mut registry = jcode_agent_runtime::AgentRegistry::new();
registry.discover_standard_paths(
home.as_deref(),
cwd.as_deref(),
);
registry.discover_standard_paths(home.as_deref(), cwd.as_deref());
if registry.is_empty() {
None
} else {
Expand Down Expand Up @@ -385,16 +382,8 @@ impl Registry {
Some("1") | Some("true") | Some("yes") | Some("on")
);
if experimental_tools_enabled && !no_builtin {
Self::insert_tool(
&mut tools_map,
"team_create",
team::TeamCreateTool::new(),
);
Self::insert_tool(
&mut tools_map,
"team_delete",
team::TeamDeleteTool::new(),
);
Self::insert_tool(&mut tools_map, "team_create", team::TeamCreateTool::new());
Self::insert_tool(&mut tools_map, "team_delete", team::TeamDeleteTool::new());
Self::insert_tool(
&mut tools_map,
"task_create",
Expand Down
5 changes: 4 additions & 1 deletion crates/jcode-app-core/src/tool/team.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ fn validate_team_name(name: &str) -> Result<()> {
name
);
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
anyhow::bail!(
"Team name '{}' is invalid: only alphanumeric, hyphen, and underscore allowed",
name
Expand Down
17 changes: 17 additions & 0 deletions crates/jcode-base/src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ pub fn build_system_prompt_with_context_and_memory(
is_selfdev,
memory_prompt,
None,
None,
)
}

Expand All @@ -219,6 +220,7 @@ pub fn build_system_prompt_full(
is_selfdev: bool,
memory_prompt: Option<&str>,
working_dir: Option<&Path>,
keyword_prompt: Option<String>,
) -> (String, ContextInfo) {
// Resolve the effective system-prompt root: CLI/env > .jcode/SYSTEM.md
// (closest to cwd, walking up to ~/.jcode/agent) > config > built-in default.
Expand Down Expand Up @@ -281,6 +283,13 @@ pub fn build_system_prompt_full(
parts.push(memory.to_string());
}

// Keyword mode prompt (changes per turn based on detected keywords)
if let Some(kw) = keyword_prompt {
if !kw.is_empty() {
parts.push(kw);
}
}

// Add available skills list
if !available_skills.is_empty() {
let mut skills_section = "# Available Skills\n\nYou have access to the following skills that the user can invoke with `/skillname`:\n".to_string();
Expand Down Expand Up @@ -313,6 +322,7 @@ pub fn build_system_prompt_split(
is_selfdev: bool,
memory_prompt: Option<&str>,
working_dir: Option<&Path>,
keyword_prompt: Option<String>,
) -> (SplitSystemPrompt, ContextInfo) {
// Resolve effective system-prompt root (issue #22).
let system_root = resolve_system_prompt_override(working_dir)
Expand Down Expand Up @@ -390,6 +400,13 @@ pub fn build_system_prompt_split(
dynamic_parts.push(memory.to_string());
}

// Keyword mode prompt (changes per turn based on detected keywords)
if let Some(kw) = keyword_prompt {
if !kw.is_empty() {
dynamic_parts.push(kw);
}
}

// Active skill prompt (changes per skill invocation)
if let Some(skill) = skill_prompt {
dynamic_parts.push(format!("# Active Skill\n\n{}", skill));
Expand Down
16 changes: 8 additions & 8 deletions crates/jcode-base/src/prompt_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ fn test_session_context_includes_time_timezone_and_system_info() {

#[test]
fn test_split_prompt_does_not_inject_session_context_per_turn() {
let (split, _info) = build_system_prompt_split(None, &[], false, None, None);
let (split, _info) = build_system_prompt_split(None, &[], false, None, None, None);
assert!(!split.dynamic_part.contains("# Session Context"));
assert!(!split.dynamic_part.contains("Time: "));
assert!(!split.dynamic_part.contains("Timezone: UTC"));
Expand Down Expand Up @@ -122,7 +122,7 @@ fn test_prompt_overlay_files_are_loaded_from_project_and_global_jcode_dirs() {
"expected global prompt overlay content"
);

let (prompt, info) = build_system_prompt_full(None, &[], false, None, Some(project_dir.path()));
let (prompt, info) = build_system_prompt_full(None, &[], false, None, Some(project_dir.path()), None);
assert!(prompt.contains("project prompt overlay instructions"));
assert!(prompt.contains("global prompt overlay instructions"));
assert!(info.prompt_overlay_chars > 0);
Expand Down Expand Up @@ -176,13 +176,13 @@ fn test_preferred_tools_files_are_loaded_from_project_and_global_jcode_dirs() {
"expected global preferred tools content"
);

let (prompt, info) = build_system_prompt_full(None, &[], false, None, Some(project_dir.path()));
let (prompt, info) = build_system_prompt_full(None, &[], false, None, Some(project_dir.path()), None);
assert!(prompt.contains("project preferred tools instructions"));
assert!(prompt.contains("global preferred tools instructions"));
assert!(info.preferred_tools_chars > 0);

let (split, split_info) =
build_system_prompt_split(None, &[], false, None, Some(project_dir.path()));
build_system_prompt_split(None, &[], false, None, Some(project_dir.path()), None);
assert!(
split
.static_part
Expand Down Expand Up @@ -223,7 +223,7 @@ fn test_selfdev_prompt_uses_full_selfdev_instructions() {
#[test]
fn test_selfdev_prompt_uses_desktop_focus_for_desktop_working_dir() {
let desktop_dir = std::path::Path::new("/tmp/jcode/crates/jcode-desktop/src");
let (prompt, _info) = build_system_prompt_full(None, &[], true, None, Some(desktop_dir));
let (prompt, _info) = build_system_prompt_full(None, &[], true, None, Some(desktop_dir), None);
assert!(prompt.contains("launched from the desktop app context"));
assert!(prompt.contains("selfdev build target=desktop"));
assert!(!prompt.contains("launched from the TUI/root jcode context"));
Expand All @@ -232,7 +232,7 @@ fn test_selfdev_prompt_uses_desktop_focus_for_desktop_working_dir() {
#[test]
fn test_split_selfdev_prompt_defaults_to_tui_focus_for_repo_root() {
let repo_dir = std::path::Path::new("/tmp/jcode");
let (split, _info) = build_system_prompt_split(None, &[], true, None, Some(repo_dir));
let (split, _info) = build_system_prompt_split(None, &[], true, None, Some(repo_dir), None);
assert!(
split
.static_part
Expand Down Expand Up @@ -266,7 +266,7 @@ fn test_selfdev_prompt_template_placeholders_are_resolved() {

#[test]
fn split_prompt_estimated_tokens_is_positive_when_populated() {
let (split, _info) = build_system_prompt_split(None, &[], false, None, None);
let (split, _info) = build_system_prompt_split(None, &[], false, None, None, None);
assert!(split.chars() > 0);
assert!(split.estimated_tokens() > 0);
}
Expand Down Expand Up @@ -370,7 +370,7 @@ fn build_system_prompt_full_uses_jcode_system_md_root() {
std::fs::create_dir_all(&dot).unwrap();
std::fs::write(dot.join("SYSTEM.md"), "MY_OVERRIDDEN_ROOT").unwrap();

let (prompt, info) = build_system_prompt_full(None, &[], false, None, Some(temp.path()));
let (prompt, info) = build_system_prompt_full(None, &[], false, None, Some(temp.path()), None);

if let Some(prev) = prev_env {
crate::env::set_var("JCODE_SYSTEM_PROMPT", prev);
Expand Down
18 changes: 18 additions & 0 deletions crates/jcode-keywords/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "jcode-keywords"
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
publish = false
description = "Magic keyword system — NL trigger detection, mode state, prompt injection, workflow dispatch"

[dependencies]
serde = { version = "1", features = ["derive"] }
toml = { version = "0.8" }
strum = { workspace = true, features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
dirs = "6"
tokio = { version = "1", features = ["rt", "time"] }

[dev-dependencies]
tempfile = "3"
Loading