feat(runtime): preserve current branch context

This commit is contained in:
2026-07-28 22:52:06 +08:00
parent 37b3397e4a
commit d0612b399c
8 changed files with 1418 additions and 85 deletions
+238 -37
View File
@@ -23,9 +23,9 @@ use serde_json::{Value, json};
use crate::{
AdjudicationModel, AdjudicationModelInput, AdjudicationModelResponse, AdjudicationToolCall,
HIDDEN_CHECK_TOOL_NAME, HiddenCheckRequest, InvalidModelOutputKind, ProviderError, TurnControl,
TurnPlan, TurnPlanProvider, compile_scene_context, encode_compiled_scene_context,
load_default_lapp_profile, provider_interruption,
BranchHistoryProjection, HIDDEN_CHECK_TOOL_NAME, HiddenCheckRequest, InvalidModelOutputKind,
ProviderError, TurnControl, TurnPlan, TurnPlanProvider, compile_scene_context_with_history,
encode_compiled_scene_prompt, load_default_lapp_profile, provider_interruption,
};
pub const TURN_PLAN_TOOL_NAME: &str = "submit_turn_plan";
@@ -503,6 +503,8 @@ async fn wait_with_turn_control<Output>(
#[derive(Debug)]
pub struct LappTurnPlanProvider<Executor> {
executor: Executor,
bundle: ResourceBundle,
branch_history: BranchHistoryProjection,
}
/// Stateful LAPP adapter for the trusted hidden-check loop.
@@ -515,6 +517,7 @@ pub struct LappTurnPlanProvider<Executor> {
pub struct LappAdjudicationModel<Executor> {
executor: Executor,
bundle: ResourceBundle,
branch_history: BranchHistoryProjection,
messages: Vec<ChatMessage>,
current_request: Option<TurnRequest>,
pending_tool_call_id: Option<String>,
@@ -526,6 +529,9 @@ impl<Executor> LappAdjudicationModel<Executor> {
Self {
executor,
bundle,
branch_history: BranchHistoryProjection {
entries: Vec::new(),
},
messages: Vec::new(),
current_request: None,
pending_tool_call_id: None,
@@ -537,6 +543,21 @@ impl<Executor> LappAdjudicationModel<Executor> {
&self.executor
}
#[must_use]
pub const fn branch_history(&self) -> &BranchHistoryProjection {
&self.branch_history
}
#[must_use]
pub fn with_branch_history(mut self, branch_history: BranchHistoryProjection) -> Self {
self.branch_history = branch_history;
self
}
pub fn set_branch_history(&mut self, branch_history: BranchHistoryProjection) {
self.branch_history = branch_history;
}
#[must_use]
pub fn into_executor(self) -> Executor {
self.executor
@@ -602,7 +623,15 @@ impl<Executor: ChatExecutor> AdjudicationModel for LappAdjudicationModel<Executo
&mut self,
input: AdjudicationModelInput<'_>,
) -> Result<AdjudicationModelResponse, ProviderError> {
self.respond_inner(input, None)
self.respond_inner(input, None, None)
}
fn respond_with_history(
&mut self,
input: AdjudicationModelInput<'_>,
branch_history: &BranchHistoryProjection,
) -> Result<AdjudicationModelResponse, ProviderError> {
self.respond_inner(input, None, Some(branch_history))
}
fn respond_with_control(
@@ -610,7 +639,16 @@ impl<Executor: ChatExecutor> AdjudicationModel for LappAdjudicationModel<Executo
input: AdjudicationModelInput<'_>,
control: &TurnControl,
) -> Result<AdjudicationModelResponse, ProviderError> {
self.respond_inner(input, Some(control))
self.respond_inner(input, Some(control), None)
}
fn respond_with_history_and_control(
&mut self,
input: AdjudicationModelInput<'_>,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<AdjudicationModelResponse, ProviderError> {
self.respond_inner(input, Some(control), Some(branch_history))
}
}
@@ -619,12 +657,19 @@ impl<Executor: ChatExecutor> LappAdjudicationModel<Executor> {
&mut self,
input: AdjudicationModelInput<'_>,
control: Option<&TurnControl>,
supplied_branch_history: Option<&BranchHistoryProjection>,
) -> Result<AdjudicationModelResponse, ProviderError> {
match input {
AdjudicationModelInput::BeginTurn { request, state } => {
let context = compile_scene_context(&self.bundle, request, state)
.map_err(|_| ProviderError::ContextEncoding)?;
let encoded = encode_compiled_scene_context(&context)
let branch_history = supplied_branch_history.unwrap_or(&self.branch_history);
let context = compile_scene_context_with_history(
&self.bundle,
request,
state,
branch_history,
)
.map_err(|_| ProviderError::ContextEncoding)?;
let encoded = encode_compiled_scene_prompt(&context)
.map_err(|_| ProviderError::ContextEncoding)?;
self.messages = vec![
ChatMessage {
@@ -724,8 +769,14 @@ impl<Executor> LappAdjudicationModel<Executor> {
impl<Executor> LappTurnPlanProvider<Executor> {
#[must_use]
pub const fn new(executor: Executor) -> Self {
Self { executor }
pub const fn new(executor: Executor, bundle: ResourceBundle) -> Self {
Self {
executor,
bundle,
branch_history: BranchHistoryProjection {
entries: Vec::new(),
},
}
}
#[must_use]
@@ -733,6 +784,21 @@ impl<Executor> LappTurnPlanProvider<Executor> {
&self.executor
}
#[must_use]
pub const fn branch_history(&self) -> &BranchHistoryProjection {
&self.branch_history
}
#[must_use]
pub fn with_branch_history(mut self, branch_history: BranchHistoryProjection) -> Self {
self.branch_history = branch_history;
self
}
pub fn set_branch_history(&mut self, branch_history: BranchHistoryProjection) {
self.branch_history = branch_history;
}
#[must_use]
pub fn into_executor(self) -> Executor {
self.executor
@@ -741,28 +807,31 @@ impl<Executor> LappTurnPlanProvider<Executor> {
impl LappTurnPlanProvider<OpenLappChatExecutor> {
/// Load the current user's LAPP profile and select its `chat` default.
pub fn from_default_profile() -> Result<Self, ProviderError> {
pub fn from_default_profile(bundle: ResourceBundle) -> Result<Self, ProviderError> {
let profile = load_default_lapp_profile()?;
Self::from_profile(&profile)
Self::from_profile(&profile, bundle)
}
pub fn from_default_profile_with_gate(
bundle: ResourceBundle,
native_call_gate: LappNativeCallGate,
) -> Result<Self, ProviderError> {
let profile = load_default_lapp_profile()?;
Self::from_profile_with_gate(&profile, native_call_gate)
Self::from_profile_with_gate(&profile, bundle, native_call_gate)
}
/// Build against an already validated LAPP profile.
pub fn from_profile(profile: &Profile) -> Result<Self, ProviderError> {
OpenLappChatExecutor::from_profile(profile).map(Self::new)
pub fn from_profile(profile: &Profile, bundle: ResourceBundle) -> Result<Self, ProviderError> {
OpenLappChatExecutor::from_profile(profile).map(|executor| Self::new(executor, bundle))
}
pub fn from_profile_with_gate(
profile: &Profile,
bundle: ResourceBundle,
native_call_gate: LappNativeCallGate,
) -> Result<Self, ProviderError> {
OpenLappChatExecutor::from_profile_with_gate(profile, native_call_gate).map(Self::new)
OpenLappChatExecutor::from_profile_with_gate(profile, native_call_gate)
.map(|executor| Self::new(executor, bundle))
}
}
@@ -772,7 +841,7 @@ impl<Executor: ChatExecutor> TurnPlanProvider for LappTurnPlanProvider<Executor>
request: &TurnRequest,
state: &RuntimeState,
) -> Result<TurnPlan, ProviderError> {
let input = build_chat_input(request, state)?;
let input = build_chat_input(&self.bundle, request, state, &self.branch_history)?;
let response = self.executor.chat(&input)?;
let plan = parse_chat_response(&response, request)?;
validate_generated_plan(request, &plan)?;
@@ -785,7 +854,34 @@ impl<Executor: ChatExecutor> TurnPlanProvider for LappTurnPlanProvider<Executor>
state: &RuntimeState,
control: &TurnControl,
) -> Result<TurnPlan, ProviderError> {
let input = build_chat_input(request, state)?;
let input = build_chat_input(&self.bundle, request, state, &self.branch_history)?;
let response = self.executor.chat_with_control(&input, control)?;
let plan = parse_chat_response(&response, request)?;
validate_generated_plan(request, &plan)?;
Ok(plan)
}
fn plan_turn_with_history(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
) -> Result<TurnPlan, ProviderError> {
let input = build_chat_input(&self.bundle, request, state, branch_history)?;
let response = self.executor.chat(&input)?;
let plan = parse_chat_response(&response, request)?;
validate_generated_plan(request, &plan)?;
Ok(plan)
}
fn plan_turn_with_history_and_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<TurnPlan, ProviderError> {
let input = build_chat_input(&self.bundle, request, state, branch_history)?;
let response = self.executor.chat_with_control(&input, control)?;
let plan = parse_chat_response(&response, request)?;
validate_generated_plan(request, &plan)?;
@@ -821,14 +917,14 @@ impl TurnPlanWire {
}
fn build_chat_input(
bundle: &ResourceBundle,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
) -> Result<ChatInput, ProviderError> {
let context = serde_json::to_string(&json!({
"request": request,
"runtimeState": state,
}))
.map_err(|_| ProviderError::ContextEncoding)?;
let context = compile_scene_context_with_history(bundle, request, state, branch_history)
.and_then(|context| encode_compiled_scene_prompt(&context))
.map_err(|_| ProviderError::ContextEncoding)?;
Ok(ChatInput {
messages: vec![
@@ -1132,8 +1228,8 @@ mod tests {
use std::time::{Duration, Instant};
use nana_domain::{
CheckResult, ResourceBundle, RuntimeState, StateOp, TurnFailureCode, TurnIntent,
TurnRequest,
BeatKind, CheckDifficulty, CheckRecord, CheckResult, ResourceBundle, RuntimeState, StateOp,
TurnFailureCode, TurnIntent, TurnRequest,
};
use openlapp::client::{ChatInput, ChatResponse, ChatRole, ToolCall};
use serde_json::{Value, json};
@@ -1144,7 +1240,8 @@ mod tests {
parse_chat_response, run_isolated_request, wait_with_turn_control,
};
use crate::{
AdjudicatingTurnPlanProvider, AdjudicationCatalog, AdjudicationModel,
AdjudicatingTurnPlanProvider, AdjudicationCatalog, AdjudicationModel, BranchHistoryBeat,
BranchHistoryCharacter, BranchHistoryEntry, BranchHistoryProjection, BranchHistoryScene,
InvalidModelOutputKind, TurnControl, TurnPlanProvider, map_provider_error,
};
@@ -1354,6 +1451,51 @@ mod tests {
.expect("embedded demo bundle")
}
fn two_turn_history() -> BranchHistoryProjection {
BranchHistoryProjection {
entries: vec![
BranchHistoryEntry {
node_id: "node_opening".into(),
player_input: "FIRST PLAYER TURN".into(),
scene: BranchHistoryScene {
id: "station_platform".into(),
title: "Old Station".into(),
},
character: BranchHistoryCharacter {
id: "nana".into(),
name: "Nana".into(),
expression: Some("guarded".into()),
pose: Some("holding_coat".into()),
},
beats: vec![BranchHistoryBeat {
kind: BeatKind::Dialogue,
speaker: Some("Nana".into()),
text: "FIRST COMMITTED REPLY".into(),
}],
},
BranchHistoryEntry {
node_id: "node_second".into(),
player_input: "SECOND PLAYER TURN".into(),
scene: BranchHistoryScene {
id: "station_platform".into(),
title: "Old Station".into(),
},
character: BranchHistoryCharacter {
id: "nana".into(),
name: "Nana".into(),
expression: Some("uncertain".into()),
pose: Some("holding_coat".into()),
},
beats: vec![BranchHistoryBeat {
kind: BeatKind::Narration,
speaker: None,
text: "SECOND COMMITTED REPLY".into(),
}],
},
],
}
}
fn response(text: String, tool_calls: Vec<ToolCall>) -> ChatResponse {
ChatResponse {
text,
@@ -1371,10 +1513,24 @@ mod tests {
fn text_json_produces_a_non_view_turn_plan_and_expected_chat_input() {
let executor =
ScriptedExecutor::returning(Ok(response(plan_value().to_string(), Vec::new())));
let mut provider = LappTurnPlanProvider::new(executor);
let mut provider = LappTurnPlanProvider::new(executor, demo_bundle());
let mut runtime = state();
runtime.checks.push(CheckRecord {
id: "HIDDEN_CHECK_CANARY".into(),
action_id: "HIDDEN_ACTION_CANARY".into(),
actor: "player".into(),
skill: "HIDDEN_SKILL_CANARY".into(),
target: 55,
difficulty: CheckDifficulty::Regular,
bonus_dice: 0,
roll: 42,
result: CheckResult::Failure,
pushed_from: None,
node_id: "node_1".into(),
});
let plan = provider
.plan_turn(&request(), &state())
.plan_turn(&request(), &runtime)
.expect("valid text plan");
assert_eq!(
@@ -1395,11 +1551,30 @@ mod tests {
assert_eq!(executor.inputs[0].messages.len(), 2);
assert_eq!(executor.inputs[0].tools.len(), 1);
assert_eq!(executor.inputs[0].tools[0].name, TURN_PLAN_TOOL_NAME);
assert!(
executor.inputs[0].messages[1]
.content
.contains("runtimeState")
);
let prompt = &executor.inputs[0].messages[1].content;
for expected in [
"\"prompt_schema_version\":1",
"\"stable_prefix\"",
"\"branch_history\"",
"\"dynamic_tail\"",
"\"character_card\"",
"独自守在废弃青川站",
] {
assert!(prompt.contains(expected), "missing {expected}");
}
for forbidden in [
"runtimeState",
"runtime_state",
"HIDDEN_CHECK_CANARY",
"HIDDEN_ACTION_CANARY",
"HIDDEN_SKILL_CANARY",
"\"roll\"",
"\"target\"",
"\"initial_relationship\"",
"\"relationship_effects\"",
] {
assert!(!prompt.contains(forbidden), "leaked {forbidden}");
}
assert!(
executor.inputs[0].messages[0]
.content
@@ -1415,7 +1590,7 @@ mod tests {
arguments: plan_value(),
};
let executor = ScriptedExecutor::returning(Ok(response(String::new(), vec![tool_call])));
let mut provider = LappTurnPlanProvider::new(executor);
let mut provider = LappTurnPlanProvider::new(executor, demo_bundle());
let plan = provider
.plan_turn(&request(), &state())
@@ -1460,7 +1635,7 @@ mod tests {
let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog);
let plan = provider
.plan_turn(&request(), &state())
.plan_turn_with_history(&request(), &state(), &two_turn_history())
.expect("hidden check then final plan");
let recorded = plan
.delta
@@ -1497,6 +1672,32 @@ mod tests {
.content
.contains("\"value\":55")
);
for expected in [
"FIRST PLAYER TURN",
"FIRST COMMITTED REPLY",
"SECOND PLAYER TURN",
"SECOND COMMITTED REPLY",
] {
assert!(
executor.inputs[0].messages[1].content.contains(expected),
"missing {expected}"
);
}
let prompt = &executor.inputs[0].messages[1].content;
assert!(
prompt.find("\"stable_prefix\"").expect("stable prefix")
< prompt.find("\"branch_history\"").expect("history")
);
assert!(
prompt.find("\"branch_history\"").expect("history")
< prompt.find("\"dynamic_tail\"").expect("dynamic tail")
);
assert!(
prompt.find("SECOND PLAYER TURN").expect("second turn")
< prompt
.find("I will return before dawn.")
.expect("current input")
);
let continuation = &executor.inputs[1].messages;
assert_eq!(continuation[2].role, ChatRole::Assistant);
@@ -1660,7 +1861,7 @@ mod tests {
}
}]);
let executor = ScriptedExecutor::returning(Ok(response(value.to_string(), Vec::new())));
let mut provider = LappTurnPlanProvider::new(executor);
let mut provider = LappTurnPlanProvider::new(executor, demo_bundle());
assert!(matches!(
provider.plan_turn(&request(), &state()),
@@ -1676,7 +1877,7 @@ mod tests {
code: Some(openlapp::ErrorCode::HttpStatus),
status: None,
}));
let mut provider = LappTurnPlanProvider::new(executor);
let mut provider = LappTurnPlanProvider::new(executor, demo_bundle());
let error = provider
.plan_turn(&request(), &state())