Skip to content

Commit 44368f1

Browse files
hayemaxijsamuel1
authored andcommitted
feat(context): load and execute context hooks (#1176)
* 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 * update enable hook func names, add serde default
1 parent 456ffe8 commit 44368f1

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: 142 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,21 @@ 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)]
31+
#[serde(default)]
2432
pub struct ContextConfig {
2533
/// List of file paths or glob patterns to include in the context.
2634
pub paths: Vec<String>,
35+
pub hooks: HookConfig,
2736
}
2837

2938
#[allow(dead_code)]
@@ -40,6 +49,8 @@ pub struct ContextManager {
4049

4150
/// Context configuration for the current profile.
4251
pub profile_config: ContextConfig,
52+
53+
pub hook_executor: HookExecutor,
4354
}
4455

4556
#[allow(dead_code)]
@@ -67,6 +78,7 @@ impl ContextManager {
6778
global_config,
6879
current_profile,
6980
profile_config,
81+
hook_executor: HookExecutor::new(),
7082
})
7183
}
7284

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

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

438574
fn profile_dir_path(ctx: &Context, profile_name: &str) -> Result<PathBuf> {
@@ -464,6 +600,7 @@ async fn load_global_config(ctx: &Context) -> Result<ContextConfig> {
464600
"README.md".to_string(),
465601
AMAZONQ_FILENAME.to_string(),
466602
],
603+
hooks: HookConfig::default(),
467604
})
468605
}
469606
}

0 commit comments

Comments
 (0)