Skip to main content

starweaver_model/request/
instructions.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3
4use crate::message::{Metadata, ModelMessage, ModelRequest, ModelRequestPart};
5
6/// Metadata key indicating whether an instruction is dynamic for prompt-cache placement.
7pub const INSTRUCTION_DYNAMIC_METADATA: &str = "starweaver_instruction_dynamic";
8/// Metadata key describing the instruction source/origin.
9pub const INSTRUCTION_ORIGIN_METADATA: &str = "starweaver_instruction_origin";
10/// Metadata key describing SDK/context prompt source/origin.
11pub const CONTEXT_ORIGIN_METADATA: &str = "starweaver_context_origin";
12/// Metadata key describing SDK/context prompt type.
13pub const CONTEXT_TYPE_METADATA: &str = "starweaver_context_type";
14
15/// Static agent instruction origin.
16pub const INSTRUCTION_ORIGIN_AGENT: &str = "agent_instruction";
17/// Toolset instruction origin.
18pub const INSTRUCTION_ORIGIN_TOOLSET: &str = "toolset";
19/// Dynamic agent instruction origin.
20pub const INSTRUCTION_ORIGIN_DYNAMIC_INSTRUCTION: &str = "dynamic_instruction";
21
22/// Runtime context origin.
23pub const CONTEXT_ORIGIN_RUNTIME_CONTEXT: &str = "runtime_context";
24/// Environment context origin.
25pub const CONTEXT_ORIGIN_ENVIRONMENT_CONTEXT: &str = "environment_context";
26/// SDK handoff context origin.
27pub const CONTEXT_ORIGIN_HANDOFF: &str = "handoff";
28/// Tool-return media control prompt origin.
29pub const CONTEXT_ORIGIN_TOOL_RETURN_MEDIA: &str = "tool_return_media";
30
31#[allow(clippy::trivially_copy_pass_by_ref)]
32const fn is_false(value: &bool) -> bool {
33    !*value
34}
35
36#[allow(clippy::redundant_pub_crate)]
37pub(crate) fn is_dynamic_instruction_metadata(metadata: &Metadata) -> bool {
38    metadata
39        .get(INSTRUCTION_DYNAMIC_METADATA)
40        .and_then(Value::as_bool)
41        .unwrap_or(false)
42}
43
44/// Return SDK/context prompt origin metadata from a request part.
45#[must_use]
46pub fn context_origin_metadata(metadata: &Metadata) -> Option<&str> {
47    metadata
48        .get(CONTEXT_ORIGIN_METADATA)
49        .and_then(Value::as_str)
50}
51
52/// Return the request whose instruction material should be applied to the current model call.
53///
54/// This mirrors Starweaver's separation between durable message history and current
55/// request `instruction_parts`: instruction material in older requests remains part of
56/// the session history but is not re-applied as current system/developer instructions.
57/// If the newest request only carries tool returns/retry prompts and has no instruction
58/// material, fall back to the preceding request's instructions for direct/replay callers.
59#[allow(clippy::redundant_pub_crate)]
60pub(crate) fn current_instruction_request_index(messages: &[ModelMessage]) -> Option<usize> {
61    let latest = messages
62        .iter()
63        .rposition(|message| matches!(message, ModelMessage::Request(_)))?;
64    let ModelMessage::Request(request) = &messages[latest] else {
65        unreachable!("latest request index points at a request")
66    };
67    if request_has_instruction_material(request) {
68        return Some(latest);
69    }
70    if request_is_control_only(request) {
71        return messages[..latest].iter().rposition(|message| {
72            matches!(message, ModelMessage::Request(request) if request_has_instruction_material(request))
73        });
74    }
75    None
76}
77
78#[allow(clippy::redundant_pub_crate)]
79pub(crate) fn request_has_instruction_material(request: &ModelRequest) -> bool {
80    request
81        .instructions
82        .as_deref()
83        .is_some_and(|instructions| !instructions.trim().is_empty())
84        || request
85            .parts
86            .iter()
87            .any(|part| matches!(part, ModelRequestPart::Instruction { .. }))
88}
89
90fn request_is_control_only(request: &ModelRequest) -> bool {
91    !request.parts.is_empty()
92        && request.parts.iter().all(|part| match part {
93            ModelRequestPart::ToolReturn(_) | ModelRequestPart::RetryPrompt { .. } => true,
94            ModelRequestPart::UserPrompt { metadata, .. } => context_origin_metadata(metadata)
95                .is_some_and(|origin| {
96                    matches!(
97                        origin,
98                        CONTEXT_ORIGIN_TOOL_RETURN_MEDIA
99                            | CONTEXT_ORIGIN_RUNTIME_CONTEXT
100                            | CONTEXT_ORIGIN_ENVIRONMENT_CONTEXT
101                            | CONTEXT_ORIGIN_HANDOFF
102                    )
103                }),
104            ModelRequestPart::SystemPrompt { .. } | ModelRequestPart::Instruction { .. } => false,
105        })
106}
107
108/// Prepared instruction fragment attached to request parameters.
109#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
110pub struct PreparedInstruction {
111    /// Instruction text.
112    pub text: String,
113    /// Whether this instruction came from a dynamic source.
114    #[serde(default, skip_serializing_if = "is_false")]
115    pub dynamic: bool,
116    /// Instruction metadata.
117    #[serde(default, skip_serializing_if = "Map::is_empty")]
118    pub metadata: Map<String, Value>,
119}
120
121impl PreparedInstruction {
122    /// Create a static instruction.
123    #[must_use]
124    pub fn static_text(text: impl Into<String>) -> Self {
125        Self {
126            text: text.into(),
127            dynamic: false,
128            metadata: Map::new(),
129        }
130    }
131
132    /// Create a dynamic instruction.
133    #[must_use]
134    pub fn dynamic_text(text: impl Into<String>) -> Self {
135        Self {
136            text: text.into(),
137            dynamic: true,
138            metadata: Map::new(),
139        }
140    }
141
142    /// Attach instruction origin metadata.
143    #[must_use]
144    pub fn with_origin(mut self, origin: impl Into<String>) -> Self {
145        self.metadata.insert(
146            INSTRUCTION_ORIGIN_METADATA.to_string(),
147            serde_json::json!(origin.into()),
148        );
149        self
150    }
151
152    /// Attach instruction dynamic metadata and update the typed dynamic flag.
153    #[must_use]
154    pub fn with_dynamic(mut self, dynamic: bool) -> Self {
155        self.dynamic = dynamic;
156        self.metadata.insert(
157            INSTRUCTION_DYNAMIC_METADATA.to_string(),
158            serde_json::json!(dynamic),
159        );
160        self
161    }
162
163    /// Sort instruction parts with static instructions before dynamic instructions.
164    #[must_use]
165    pub fn sorted(instructions: &[Self]) -> Vec<Self> {
166        let mut sorted = instructions.to_vec();
167        sorted.sort_by_key(|instruction| instruction.dynamic);
168        sorted
169    }
170
171    pub(super) fn to_request_part(&self) -> ModelRequestPart {
172        let mut metadata = self.metadata.clone();
173        metadata.insert(
174            INSTRUCTION_DYNAMIC_METADATA.to_string(),
175            serde_json::json!(self.dynamic),
176        );
177        ModelRequestPart::Instruction {
178            text: self.text.clone(),
179            metadata,
180        }
181    }
182}
183
184/// Alias for structured instruction parts.
185pub type InstructionPart = PreparedInstruction;