Skip to content

Commit b410760

Browse files
committed
feat(context): load and execute context hooks
RFC: #1050 This PR begins implements the required logic for executing hooks. This PR has pending follow ups to handle the remaining items. Note that the logic is currently inaccessible from a normal chat session. It implements: - "Hooks", which are defined in the context json files. They are loaded along with the normal context. - "conversation_start" hooks that run once and are attached to the top of the context, - "per_prompt" hooks that run each time there is a prompt, and are attached to the user prompt. - Start with 1 hook type, "inline hooks", which simple execute a bash command. - A HookExecutor to asynchronously call hooks in order from the configs. - Logic to add/remove/enable/disable hooks via commands - A cache to hold recent executions of hooks to avoid calling them too much. - "conversation_start" hooks are held in cache indefinitely. "per_prompt" hooks can have - The bash execution logic from `exceute_bash` tool was moved to a helper function so that it can be re-used here. Remaining items in another PR: - Call the hooks during a chat session and attach them to context appropriately - Commands to enable, disable, add, and show hooks - Add `Criticality` as according to the spec - Handle user having multiple hooks with the same name (enforce uniqueness) - Remaining tests
1 parent bcd0c0b commit b410760

File tree

4 files changed

+526
-88
lines changed

4 files changed

+526
-88
lines changed

crates/q_cli/src/cli/chat/context.rs

Lines changed: 143 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::io::Write;
12
use std::path::{
23
Path,
34
PathBuf,
@@ -17,13 +18,20 @@ use serde::{
1718
Serialize,
1819
};
1920

21+
use super::hooks::{
22+
Hook,
23+
HookExecutor,
24+
};
25+
use crate::cli::chat::hooks::HookConfig;
26+
2027
pub const AMAZONQ_FILENAME: &str = "AmazonQ.md";
2128

2229
/// Configuration for context files, containing paths to include in the context.
2330
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2431
pub struct ContextConfig {
2532
/// List of file paths or glob patterns to include in the context.
2633
pub paths: Vec<String>,
34+
pub hooks: HookConfig,
2735
}
2836

2937
#[allow(dead_code)]
@@ -40,6 +48,8 @@ pub struct ContextManager {
4048

4149
/// Context configuration for the current profile.
4250
pub profile_config: ContextConfig,
51+
52+
pub hook_executor: HookExecutor,
4353
}
4454

4555
#[allow(dead_code)]
@@ -67,6 +77,7 @@ impl ContextManager {
6777
global_config,
6878
current_profile,
6979
profile_config,
80+
hook_executor: HookExecutor::new(),
7081
})
7182
}
7283

@@ -157,11 +168,7 @@ impl ContextManager {
157168
/// A Result indicating success or an error
158169
pub async fn remove_paths(&mut self, paths: Vec<String>, global: bool) -> Result<()> {
159170
// Get reference to the appropriate config
160-
let config = if global {
161-
&mut self.global_config
162-
} else {
163-
&mut self.profile_config
164-
};
171+
let config = self.get_config_mut(global);
165172

166173
// Track if any paths were removed
167174
let mut removed_any = false;
@@ -433,6 +440,136 @@ impl ContextManager {
433440
}
434441
Ok(())
435442
}
443+
444+
fn get_config_mut(&mut self, global: bool) -> &mut ContextConfig {
445+
if global {
446+
&mut self.global_config
447+
} else {
448+
&mut self.profile_config
449+
}
450+
}
451+
452+
/// Add hooks to the context config. If another hook with the same name already exists, throw an
453+
/// error.
454+
///
455+
/// # Arguments
456+
/// * `hook` - name of the hook to delete
457+
/// * `global` - If true, the add to the global config. If false, add to the current profile
458+
/// config.
459+
/// * `conversation_start` - If true, add the hook to conversation_start. Otherwise, it will be
460+
/// added to per_prompt.
461+
pub async fn add_hook(&mut self, hook: Hook, global: bool, conversation_start: bool) -> Result<()> {
462+
if self.num_hooks_with_name(&hook.name) > 0 {
463+
return Err(eyre!(
464+
"Cannot add hook, another hook with this name already exists in global or profile context."
465+
));
466+
}
467+
468+
let config = self.get_config_mut(global);
469+
470+
let hook_vec = if conversation_start {
471+
&mut config.hooks.conversation_start
472+
} else {
473+
&mut config.hooks.per_prompt
474+
};
475+
476+
hook_vec.push(hook);
477+
self.save_config(global).await
478+
}
479+
480+
fn num_hooks_with_name(&self, name: &str) -> usize {
481+
self.global_config
482+
.hooks
483+
.conversation_start
484+
.iter()
485+
.chain(self.global_config.hooks.per_prompt.iter())
486+
.chain(self.profile_config.hooks.conversation_start.iter())
487+
.chain(self.profile_config.hooks.per_prompt.iter())
488+
.filter(|h| h.name == name)
489+
.count()
490+
}
491+
492+
/// Delete hook(s) by name
493+
/// # Arguments
494+
/// * `name` - name of the hook to delete
495+
/// * `global` - If true, the delete from the global config. If false, delete from the current
496+
/// profile config
497+
pub async fn remove_hook(&mut self, name: &str, global: bool) -> Result<()> {
498+
let config = self.get_config_mut(global);
499+
500+
config.hooks.conversation_start.retain(|h| h.name != name);
501+
config.hooks.per_prompt.retain(|h| h.name != name);
502+
503+
self.save_config(global).await
504+
}
505+
506+
/// Sets the "disabled" field on any [`Hook`] with the given name to be true or false
507+
/// # Arguments
508+
/// * `enable` - If true, the "disabled" field are is to false. If false, the "disabled" field
509+
/// is set to true
510+
pub async fn enable_hook(&mut self, name: &str, global: bool, enable: bool) -> Result<()> {
511+
let config = self.get_config_mut(global);
512+
513+
config
514+
.hooks
515+
.conversation_start
516+
.iter_mut()
517+
.chain(config.hooks.per_prompt.iter_mut())
518+
.filter(|h| h.name == name)
519+
.for_each(|h| h.disabled = !enable);
520+
521+
self.save_config(global).await
522+
}
523+
524+
/// Sets the "disabled" field on all [`Hook`]s to be true or false
525+
/// # Arguments
526+
/// * `enable` - If true, all "disabled" fields are set to false. If false, all "disabled"
527+
/// fields are set to true
528+
pub async fn enable_all_hooks(&mut self, global: bool, enable: bool) -> Result<()> {
529+
let config = self.get_config_mut(global);
530+
531+
config
532+
.hooks
533+
.conversation_start
534+
.iter_mut()
535+
.chain(config.hooks.per_prompt.iter_mut())
536+
.for_each(|h| h.disabled = !enable);
537+
538+
self.save_config(global).await
539+
}
540+
541+
/// Run all the currently enabled hooks from both the global and profile contexts
542+
/// # Arguments
543+
/// * `updates` - output stream to write hook run status to
544+
/// # Returns
545+
/// A vector containing pairs of a [`Hook`] definition and its execution output
546+
pub async fn run_hooks(&mut self, updates: &mut impl Write) -> Vec<(Hook, String)> {
547+
let mut hooks: Vec<&Hook> = Vec::new();
548+
549+
// Collect all conversation start hooks
550+
hooks.extend(
551+
self.global_config
552+
.hooks
553+
.conversation_start
554+
.iter_mut()
555+
.chain(self.profile_config.hooks.conversation_start.iter_mut())
556+
.map(|h| {
557+
h.is_conversation_start = true;
558+
&*h
559+
}),
560+
);
561+
562+
// Collect all per-prompt hooks
563+
hooks.extend(
564+
self.global_config
565+
.hooks
566+
.per_prompt
567+
.iter()
568+
.chain(self.profile_config.hooks.per_prompt.iter()),
569+
);
570+
571+
self.hook_executor.run_hooks(hooks, updates).await
572+
}
436573
}
437574

438575
fn profile_dir_path(ctx: &Context, profile_name: &str) -> Result<PathBuf> {
@@ -464,6 +601,7 @@ async fn load_global_config(ctx: &Context) -> Result<ContextConfig> {
464601
"README.md".to_string(),
465602
AMAZONQ_FILENAME.to_string(),
466603
],
604+
hooks: HookConfig::default(),
467605
})
468606
}
469607
}

0 commit comments

Comments
 (0)