Skip to content
Merged
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
25 changes: 11 additions & 14 deletions src/agent/cognitive.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import gleam/int
import gleam/list
import gleam/option.{None, Some}
import gleam/string
import llm/message_history
import llm/response
import llm/types as llm_types
import meta/log as meta_log
Expand Down Expand Up @@ -121,7 +122,7 @@ pub fn start(
max_tokens: cfg.max_tokens,
max_context_messages: cfg.max_context_messages,
tools:,
messages: cfg.initial_messages,
messages: message_history.from_list(cfg.initial_messages),
registry: cfg.registry,
pending: dict.new(),
status: Idle,
Expand Down Expand Up @@ -344,7 +345,9 @@ fn handle_message(
types.SetScheduler(scheduler) ->
CognitiveState(..state, scheduler: Some(scheduler))
types.GetMessages(reply_to:) -> {
process.send(reply_to, state.messages)
// GetMessages is the WS / TUI snapshot path — they want a flat
// List(Message) for rendering. for_send is the wire export.
process.send(reply_to, message_history.for_send(state.messages))
state
}
types.Ping(reply_to:) -> {
Expand Down Expand Up @@ -1147,13 +1150,12 @@ fn handle_think_complete(
state.cycle_id,
)
let new_task_id = cycle_log.generate_uuid()
let nudge_msg =
llm_types.Message(role: llm_types.User, content: [
let retry_messages =
message_history.add_user(state.messages, [
llm_types.TextContent(
"Your previous response was empty. Please provide a substantive response.",
),
])
let retry_messages = list.append(state.messages, [nudge_msg])
let req =
cognitive_llm.build_request_with_model(
state,
Expand Down Expand Up @@ -1200,8 +1202,8 @@ fn handle_think_complete(
state.cycle_id,
)
let new_task_id = cycle_log.generate_uuid()
let nudge_msg =
llm_types.Message(role: llm_types.User, content: [
let retry_messages =
message_history.add_user(state.messages, [
llm_types.TextContent(
"Your previous response was cut off at the token cap"
<> " (output_tokens="
Expand All @@ -1222,7 +1224,6 @@ fn handle_think_complete(
<> " a different result.",
),
])
let retry_messages = list.append(state.messages, [nudge_msg])
let req =
cognitive_llm.build_request_with_model(
state,
Expand Down Expand Up @@ -1299,12 +1300,8 @@ fn handle_think_complete(
)
None -> #(text, req_model)
}
let assistant_msg =
llm_types.Message(
role: llm_types.Assistant,
content: resp.content,
)
let messages = list.append(state.messages, [assistant_msg])
let messages =
message_history.add_assistant(state.messages, resp.content)
// Output gate strategy:
// - Autonomous (scheduler) cycles: full LLM scorer + normative
// calculus — nobody's watching, quality matters before delivery
Expand Down
62 changes: 26 additions & 36 deletions src/agent/cognitive/agents.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string
import knowledge/search as knowledge_search
import llm/message_history
import llm/response
import llm/types as llm_types
import narrative/appraiser
Expand Down Expand Up @@ -211,9 +212,7 @@ fn handle_own_human_input(
frontdoor_types.CognitiveLoopOrigin,
)

let assistant_msg =
llm_types.Message(role: llm_types.Assistant, content: resp.content)
let messages = list.append(state.messages, [assistant_msg])
let messages = message_history.add_assistant(state.messages, resp.content)

let ctx = OwnToolWaiting(tool_use_id: call.id)

Expand Down Expand Up @@ -472,11 +471,10 @@ fn handle_memory_tools(
case remaining_calls {
[] -> {
// Only memory calls — add results to messages and re-think
let assistant_msg =
llm_types.Message(role: llm_types.Assistant, content: resp.content)
let user_msg =
llm_types.Message(role: llm_types.User, content: memory_results)
let messages = list.append(state.messages, [assistant_msg, user_msg])
let messages =
state.messages
|> message_history.add_assistant(resp.content)
|> message_history.add_user(memory_results)

let new_task_id = cycle_log.generate_uuid()
let cycle_id = option.unwrap(state.cycle_id, new_task_id)
Expand Down Expand Up @@ -537,11 +535,10 @@ fn handle_memory_tools(
case agent_calls {
[] -> {
// No agent calls either — just memory + unknown tools, re-think
let assistant_msg =
llm_types.Message(role: llm_types.Assistant, content: resp.content)
let user_msg =
llm_types.Message(role: llm_types.User, content: initial)
let messages = list.append(state.messages, [assistant_msg, user_msg])
let messages =
state.messages
|> message_history.add_assistant(resp.content)
|> message_history.add_user(initial)
let new_task_id = cycle_log.generate_uuid()
let cycle_id = option.unwrap(state.cycle_id, new_task_id)
// Check for mid-cycle escalation before re-thinking
Expand Down Expand Up @@ -625,9 +622,7 @@ fn dispatch_agent_calls(
"" -> "No agent tools matched."
t -> t
}
let assistant_msg =
llm_types.Message(role: llm_types.Assistant, content: resp.content)
let messages = list.append(state.messages, [assistant_msg])
let messages = message_history.add_assistant(state.messages, resp.content)
output.send_reply(
state,
reply_text,
Expand Down Expand Up @@ -996,8 +991,6 @@ fn do_dispatch_agents(
// that, every subsequent cycle re-sends this orphaned tool_use
// and the API 400s. Emit a user message with synthesised error
// tool_result blocks for every tool_use we refused to dispatch.
let assistant_msg =
llm_types.Message(role: llm_types.Assistant, content: resp.content)
let tool_use_calls =
list.filter_map(resp.content, fn(block) {
case block {
Expand All @@ -1014,11 +1007,12 @@ fn do_dispatch_agents(
is_error: True,
)
})
let user_msg =
llm_types.Message(role: llm_types.User, content: error_results)
let messages = case error_results {
[] -> list.append(state.messages, [assistant_msg])
_ -> list.append(state.messages, [assistant_msg, user_msg])
[] -> message_history.add_assistant(state.messages, resp.content)
_ ->
state.messages
|> message_history.add_assistant(resp.content)
|> message_history.add_user(error_results)
}
CognitiveState(
..state,
Expand All @@ -1045,9 +1039,7 @@ fn do_dispatch_agents(
let pending_ids = list.append(agent_pending_ids, coder_pending_ids)

// Add assistant message with tool use content
let assistant_msg =
llm_types.Message(role: llm_types.Assistant, content: resp.content)
let messages = list.append(state.messages, [assistant_msg])
let messages = message_history.add_assistant(state.messages, resp.content)

// Insert new pending entries (agents + coder dispatches) into the dict
let new_pending =
Expand Down Expand Up @@ -1488,10 +1480,11 @@ pub fn handle_agent_complete(
_ -> [tool_result_block]
}

// Build ONE user message with ALL accumulated results
let user_msg =
llm_types.Message(role: llm_types.User, content: all_results)
let messages = list.append(state.messages, [user_msg])
// Build ONE user message with ALL accumulated results.
// MessageHistory.add_user strips any orphan tool_result whose
// tool_use_id isn't paired with a tool_use in the prior assistant
// message — invariant maintained by construction.
let messages = message_history.add_user(state.messages, all_results)

// Spawn post-execution D' re-check if enabled
let result_text =
Expand Down Expand Up @@ -1661,9 +1654,7 @@ pub fn handle_coder_dispatch_complete(
list.append(accumulated_results, [tool_result_block])
_ -> [tool_result_block]
}
let user_msg =
llm_types.Message(role: llm_types.User, content: all_results)
let messages = list.append(state.messages, [user_msg])
let messages = message_history.add_user(state.messages, all_results)
let new_task_id = cycle_log.generate_uuid()
let cycle_id = option.unwrap(state.cycle_id, new_task_id)
let state = CognitiveState(..state, messages:, pending: remaining)
Expand Down Expand Up @@ -1922,9 +1913,8 @@ pub fn handle_user_answer(
content: answer,
is_error: False,
)
let user_msg =
llm_types.Message(role: llm_types.User, content: [tool_result_block])
let messages = list.append(state.messages, [user_msg])
let messages =
message_history.add_user(state.messages, [tool_result_block])

// Spawn a continuation think worker
let new_task_id = cycle_log.generate_uuid()
Expand Down Expand Up @@ -2342,7 +2332,7 @@ pub fn dispatch_deferred(
})
CognitiveState(
..state,
messages: list.append(state.messages, error_results),
messages: message_history.add_all(state.messages, error_results),
)
}
True -> {
Expand Down
75 changes: 25 additions & 50 deletions src/agent/cognitive/llm.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import gleam/int
import gleam/list
import gleam/option.{None, Some}
import gleam/string
import llm/message_repair
import llm/message_history.{type MessageHistory}
import llm/request
import llm/types as llm_types
import meta/types as meta_types
Expand Down Expand Up @@ -60,7 +60,7 @@ pub fn proceed_with_model(
queue_depth: list.length(state.input_queue),
session_since: state.identity.session_since,
agents_active: registry.count_running(state.registry),
message_count: list.length(state.messages),
message_count: message_history.length(state.messages),
sensory_events: state.pending_sensory_events,
active_delegations: dict.values(state.active_delegations),
sandbox_enabled: state.config.sandbox_enabled,
Expand All @@ -81,11 +81,7 @@ pub fn proceed_with_model(
// Consume pending Layer 3b meta intervention if any
let state = consume_meta_intervention(state, cycle_id)

let msg =
llm_types.Message(role: llm_types.User, content: [
llm_types.TextContent(text:),
])
let messages = list.append(state.messages, [msg])
let messages = message_history.add_user_text(state.messages, text)
let task_id = cycle_id

let req = build_request_with_model(state, model, messages)
Expand Down Expand Up @@ -230,11 +226,10 @@ pub fn handle_think_error(
// well-formed (alternating user/assistant). Without this, the
// next user input would create two consecutive user messages
// and the API would reject the request.
let error_msg =
llm_types.Message(role: llm_types.Assistant, content: [
let messages =
message_history.add_assistant(state.messages, [
llm_types.TextContent(text: user_text),
])
let messages = list.append(state.messages, [error_msg])
CognitiveState(
..state,
messages:,
Expand Down Expand Up @@ -267,11 +262,10 @@ pub fn handle_think_down(
)
let user_text = render_user_error(InternalCrash)
output.send_reply(state, user_text, state.model, None, [])
let error_msg =
llm_types.Message(role: llm_types.Assistant, content: [
let messages =
message_history.add_assistant(state.messages, [
llm_types.TextContent(text: user_text),
])
let messages = list.append(state.messages, [error_msg])
CognitiveState(
..state,
messages:,
Expand All @@ -286,29 +280,34 @@ pub fn handle_think_down(
/// Build an LLM request using the current model.
pub fn build_request(
state: CognitiveState,
messages: List(llm_types.Message),
messages: MessageHistory,
) -> llm_types.LlmRequest {
build_request_with_model(state, state.model, messages)
}

/// Build an LLM request with a specific model.
///
/// The `MessageHistory` is invariant-bearing by construction (see
/// `llm/message_history.gleam`): orphan tool_uses, orphan
/// tool_results, leading-assistant, and same-role-runs are all
/// impossible to introduce. The reactive `repair_orphans_and_warn`
/// pipeline this function used to call is gone — there's nothing
/// left to repair.
///
/// What remains here are the *quantitative* trims that depend on
/// runtime knobs (`max_context_messages`) and the hard token-budget
/// safety net. Those still apply to the wire-side `List(Message)`.
pub fn build_request_with_model(
state: CognitiveState,
model: String,
messages: List(llm_types.Message),
messages: MessageHistory,
) -> llm_types.LlmRequest {
// Defensive repair: inject synthetic tool_result blocks for any
// orphaned tool_use ids. Anthropic's API rejects histories where an
// assistant tool_use isn't immediately followed by a matching
// tool_result; once an orphan lands in state.messages, every cycle
// keeps sending it until something repairs it. This is the last
// line of defence — upstream paths still need to be tidy. We
// slog.warn on any repair so orphan sources stay visible.
let repaired = repair_orphans_and_warn(messages, state.cycle_id)
// Message count trim (configurable)
let raw = message_history.for_send(messages)
// Message count trim (configurable). `context.trim` keeps the
// tool_use/tool_result pairing intact even after dropping by count.
let trimmed = case state.max_context_messages {
None -> context.ensure_alternation(repaired)
Some(max) -> context.trim(repaired, max)
None -> context.ensure_alternation(raw)
Some(max) -> context.trim(raw, max)
}
// Token budget safety net — hard cap to prevent API 400 errors.
// System prompt + tools + response budget need headroom, so cap messages
Expand All @@ -329,30 +328,6 @@ pub fn build_request_with_model(
}
}

/// Wrap `message_repair.repair` with a warn log when it has to act.
/// Each orphan is a bug upstream — we want the logs to point operators
/// (and us) at the code path that left it dangling.
fn repair_orphans_and_warn(
messages: List(llm_types.Message),
cycle_id: option.Option(String),
) -> List(llm_types.Message) {
case message_repair.find_orphans(messages) {
[] -> messages
orphans -> {
slog.warn(
"cognitive/llm",
"build_request",
"Repairing "
<> int.to_string(list.length(orphans))
<> " orphaned tool_use id(s): "
<> string.join(orphans, ", "),
cycle_id,
)
message_repair.repair(messages)
}
}
}

// ---------------------------------------------------------------------------
// Layer 3b meta intervention
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading