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
2 changes: 2 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ export interface CortexSection {
bulletin_interval_secs: number;
bulletin_max_words: number;
bulletin_max_turns: number;
auto_display_name: boolean;
}

export interface CoalesceSection {
Expand Down Expand Up @@ -570,6 +571,7 @@ export interface CortexUpdate {
bulletin_interval_secs?: number;
bulletin_max_words?: number;
bulletin_max_turns?: number;
auto_display_name?: boolean;
}

export interface CoalesceUpdate {
Expand Down
6 changes: 6 additions & 0 deletions interface/src/routes/AgentConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,12 @@ function ConfigSectionEditor({ sectionId, label, description, detail, config, on
case "cortex":
return (
<div className="grid gap-4">
<ConfigToggleField
label="Auto Display Name"
description="When enabled, cortex generates a creative display name. When disabled, the agent ID is used as display name."
value={localValues.auto_display_name as boolean}
onChange={(v) => handleChange("auto_display_name", v)}
/>
<NumberStepper
label="Tick Interval"
description="How often the cortex checks system state"
Expand Down
4 changes: 2 additions & 2 deletions interface/src/routes/AgentDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,9 @@ function HeroSection({
</div>
<button
onClick={onDelete}
className="rounded-md px-3 py-1.5 text-sm text-ink-faint transition-colors hover:bg-red-500/10 hover:text-red-400"
className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm font-medium text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300"
>
Delete
Delete Agent
</button>
</div>

Expand Down
2 changes: 1 addition & 1 deletion interface/src/ui/forms/SwitchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const SwitchField: React.FC<SwitchFieldProps> = ({
</div>
<Toggle
{...props}
checked={field.value}
checked={field.value ?? false}
onCheckedChange={field.onChange}
/>
</label>
Expand Down
69 changes: 40 additions & 29 deletions src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,10 @@ impl Channel {
_ = tokio::time::sleep(sleep_duration), if next_deadline.is_some() => {
let now = tokio::time::Instant::now();
// Check coalesce deadline
if self.coalesce_deadline.is_some_and(|d| d <= now) {
if let Err(error) = self.flush_coalesce_buffer().await {
if self.coalesce_deadline.is_some_and(|d| d <= now)
&& let Err(error) = self.flush_coalesce_buffer().await {
tracing::error!(%error, channel_id = %self.id, "error flushing coalesce buffer on deadline");
}
}
// Check retrigger deadline
if self.retrigger_deadline.is_some_and(|d| d <= now) {
self.flush_pending_retrigger().await;
Expand Down Expand Up @@ -391,7 +390,10 @@ impl Channel {

if messages.len() == 1 {
// Single message - process normally
let message = messages.into_iter().next().ok_or_else(|| anyhow::anyhow!("empty iterator after length check"))?;
let message = messages
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("empty iterator after length check"))?;
self.handle_message(message).await
} else {
// Multiple messages - batch them
Expand Down Expand Up @@ -462,10 +464,11 @@ impl Channel {
.get("telegram_chat_type")
.and_then(|v| v.as_str())
});
self.conversation_context = Some(
prompt_engine
.render_conversation_context(&first.source, server_name, channel_name)?,
);
self.conversation_context = Some(prompt_engine.render_conversation_context(
&first.source,
server_name,
channel_name,
)?);
}

// Persist each message to conversation log (individual audit trail)
Expand Down Expand Up @@ -605,8 +608,11 @@ impl Channel {
let browser_enabled = rc.browser_config.load().enabled;
let web_search_enabled = rc.brave_search_key.load().is_some();
let opencode_enabled = rc.opencode.load().enabled;
let worker_capabilities =
prompt_engine.render_worker_capabilities(browser_enabled, web_search_enabled, opencode_enabled)?;
let worker_capabilities = prompt_engine.render_worker_capabilities(
browser_enabled,
web_search_enabled,
opencode_enabled,
)?;

let status_text = {
let status = self.state.status_block.read().await;
Expand Down Expand Up @@ -712,10 +718,11 @@ impl Channel {
.get("telegram_chat_type")
.and_then(|v| v.as_str())
});
self.conversation_context = Some(
prompt_engine
.render_conversation_context(&message.source, server_name, channel_name)?,
);
self.conversation_context = Some(prompt_engine.render_conversation_context(
&message.source,
server_name,
channel_name,
)?);
}

let system_prompt = self.build_system_prompt().await?;
Expand Down Expand Up @@ -802,8 +809,11 @@ impl Channel {
let browser_enabled = rc.browser_config.load().enabled;
let web_search_enabled = rc.brave_search_key.load().is_some();
let opencode_enabled = rc.opencode.load().enabled;
let worker_capabilities = prompt_engine
.render_worker_capabilities(browser_enabled, web_search_enabled, opencode_enabled)?;
let worker_capabilities = prompt_engine.render_worker_capabilities(
browser_enabled,
web_search_enabled,
opencode_enabled,
)?;

let status_text = {
let status = self.state.status_block.read().await;
Expand All @@ -814,17 +824,16 @@ impl Channel {

let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) };

prompt_engine
.render_channel_prompt(
empty_to_none(identity_context),
empty_to_none(memory_bulletin.to_string()),
empty_to_none(skills_prompt),
worker_capabilities,
self.conversation_context.clone(),
empty_to_none(status_text),
None, // coalesce_hint - only set for batched messages
available_channels,
)
prompt_engine.render_channel_prompt(
empty_to_none(identity_context),
empty_to_none(memory_bulletin.to_string()),
empty_to_none(skills_prompt),
worker_capabilities,
self.conversation_context.clone(),
empty_to_none(status_text),
None, // coalesce_hint - only set for batched messages
available_channels,
)
}

/// Register per-turn tools, run the LLM agentic loop, and clean up.
Expand Down Expand Up @@ -1147,8 +1156,10 @@ impl Channel {
for (key, value) in retrigger_metadata {
self.pending_retrigger_metadata.insert(key, value);
}
self.retrigger_deadline =
Some(tokio::time::Instant::now() + std::time::Duration::from_millis(RETRIGGER_DEBOUNCE_MS));
self.retrigger_deadline = Some(
tokio::time::Instant::now()
+ std::time::Duration::from_millis(RETRIGGER_DEBOUNCE_MS),
);
}
}

Expand Down
30 changes: 30 additions & 0 deletions src/agent/cortex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -584,8 +584,38 @@ struct ProfileLlmResponse {
///
/// Uses the current memory bulletin and identity files as context, then asks
/// an LLM to produce a display name, status line, and short bio.
///
/// When `auto_display_name` is disabled, the display name is set to the agent
/// ID and cortex will not overwrite it.
#[tracing::instrument(skip(deps, logger), fields(agent_id = %deps.agent_id))]
async fn generate_profile(deps: &AgentDeps, logger: &CortexLogger) {
let cortex_config = **deps.runtime_config.cortex.load();

// If auto_display_name is disabled, ensure the profile exists with
// display_name = agent_id and only update status/bio fields.
if !cortex_config.auto_display_name {
let agent_id = &deps.agent_id;
let avatar_seed = agent_id.to_string();
if let Err(error) = sqlx::query(
"INSERT INTO agent_profile (agent_id, display_name, avatar_seed, generated_at, updated_at) \
VALUES (?, ?, ?, datetime('now'), datetime('now')) \
ON CONFLICT(agent_id) DO UPDATE SET \
display_name = COALESCE(agent_profile.display_name, excluded.display_name), \
avatar_seed = excluded.avatar_seed, \
updated_at = datetime('now')",
)
.bind(agent_id.as_ref())
.bind(agent_id.as_ref())
.bind(&avatar_seed)
.execute(&deps.sqlite_pool)
.await
{
tracing::warn!(%error, "failed to ensure agent profile with agent_id as display_name");
}
tracing::info!("auto_display_name disabled, skipping cortex profile generation");
return;
}

tracing::info!("cortex generating agent profile");
let started = Instant::now();

Expand Down
34 changes: 23 additions & 11 deletions src/agent/cortex_chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ impl<M: CompletionModel> PromptHook<M> for CortexChatHook {
) -> ToolCallHookAction {
self.send(CortexChatEvent::ToolStarted {
tool: tool_name.to_string(),
}).await;
})
.await;
ToolCallHookAction::Continue
}

Expand All @@ -95,7 +96,8 @@ impl<M: CompletionModel> PromptHook<M> for CortexChatHook {
self.send(CortexChatEvent::ToolCompleted {
tool: tool_name.to_string(),
result_preview: preview,
}).await;
})
.await;
HookAction::Continue
}

Expand Down Expand Up @@ -295,26 +297,33 @@ impl CortexChatSession {
let _ = store
.save_message(&thread_id, "assistant", &response, channel_ref)
.await;
let _ = event_tx.send(CortexChatEvent::Done {
full_text: response,
}).await;
let _ = event_tx
.send(CortexChatEvent::Done {
full_text: response,
})
.await;
}
Err(error) => {
let error_text = format!("Cortex chat error: {error}");
let _ = store
.save_message(&thread_id, "assistant", &error_text, channel_ref)
.await;
let _ = event_tx.send(CortexChatEvent::Error {
message: error_text,
}).await;
let _ = event_tx
.send(CortexChatEvent::Error {
message: error_text,
})
.await;
}
}
});

Ok(event_rx)
}

async fn build_system_prompt(&self, channel_context_id: Option<&str>) -> crate::error::Result<String> {
async fn build_system_prompt(
&self,
channel_context_id: Option<&str>,
) -> crate::error::Result<String> {
let runtime_config = &self.deps.runtime_config;
let prompt_engine = runtime_config.prompts.load();

Expand All @@ -324,8 +333,11 @@ impl CortexChatSession {
let browser_enabled = runtime_config.browser_config.load().enabled;
let web_search_enabled = runtime_config.brave_search_key.load().is_some();
let opencode_enabled = runtime_config.opencode.load().enabled;
let worker_capabilities =
prompt_engine.render_worker_capabilities(browser_enabled, web_search_enabled, opencode_enabled)?;
let worker_capabilities = prompt_engine.render_worker_capabilities(
browser_enabled,
web_search_enabled,
opencode_enabled,
)?;

// Load channel transcript if a channel context is active
let channel_transcript = if let Some(channel_id) = channel_context_id {
Expand Down
23 changes: 18 additions & 5 deletions src/agent/ingestion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,11 +298,24 @@ async fn read_ingest_content(path: &Path) -> anyhow::Result<String> {
.await
.with_context(|| format!("failed to read pdf file: {}", path.display()))?;

let extracted =
tokio::task::spawn_blocking(move || pdf_extract::extract_text_from_mem(&bytes))
.await
.context("pdf extraction task failed")?
.with_context(|| format!("failed to extract text from pdf: {}", path.display()))?;
let extracted = tokio::task::spawn_blocking(move || {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
pdf_extract::extract_text_from_mem(&bytes)
})) {
Ok(result) => result.map_err(|e| anyhow::anyhow!(e)),
Err(panic) => {
let msg = panic
.downcast_ref::<&str>()
.copied()
.or_else(|| panic.downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("unknown panic");
Err(anyhow::anyhow!("pdf extraction panicked: {msg}"))
}
}
})
.await
.context("pdf extraction task failed")?
.with_context(|| format!("failed to extract text from pdf: {}", path.display()))?;

return Ok(extracted);
}
Expand Down
8 changes: 5 additions & 3 deletions src/agent/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,10 @@ impl Worker {
None
}
})
.unwrap_or_else(|| "Worker reached maximum segments without a final response.".to_string());
.unwrap_or_else(|| {
"Worker reached maximum segments without a final response."
.to_string()
});
}

self.maybe_compact_history(&mut history).await;
Expand Down Expand Up @@ -358,8 +361,7 @@ impl Worker {
self.hook.send_status("compacting (overflow recovery)");
self.force_compact_history(&mut history).await;
let prompt_engine = self.deps.runtime_config.prompts.load();
let overflow_msg =
prompt_engine.render_system_worker_overflow()?;
let overflow_msg = prompt_engine.render_system_worker_overflow()?;
follow_up_prompt = format!("{follow_up}\n\n{overflow_msg}");
}
Err(error) => {
Expand Down
Loading