feat(runtime): preserve current branch context
This commit is contained in:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user