use std::fmt;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures_util::{Stream, StreamExt};
use tokio::sync::{mpsc, oneshot};
use crate::{
KitError, KnowledgePolicy, ModelSelector, PersonalitySelector, ReasoningEffort, ToolSelection,
WorkspaceContext,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StructuredOutputRequest {
schema: serde_json::Value,
}
#[derive(Clone, Copy, Debug)]
struct StructuredOutputLocalRetriever;
impl fmt::Display for StructuredOutputLocalRetriever {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("structured output schema references must remain local")
}
}
impl std::error::Error for StructuredOutputLocalRetriever {}
impl jsonschema::Retrieve for StructuredOutputLocalRetriever {
fn retrieve(
&self,
_uri: &jsonschema::Uri<String>,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
Err(Box::new(*self))
}
}
impl StructuredOutputRequest {
pub fn new(schema: serde_json::Value) -> Result<Self, KitError> {
if schema.as_object().is_none_or(|object| object.is_empty()) {
return Err(KitError::invalid_input(
"structured output schema must be a nonempty JSON object",
));
}
jsonschema::draft202012::meta::validate(&schema).map_err(|_| {
KitError::invalid_input("structured output schema is not valid Draft 2020-12")
})?;
jsonschema::draft202012::options()
.with_retriever(StructuredOutputLocalRetriever)
.build(&schema)
.map_err(|_| {
KitError::invalid_input(
"structured output schema must compile as a self-contained document",
)
})?;
Ok(Self { schema })
}
#[must_use]
pub fn schema(&self) -> &serde_json::Value {
&self.schema
}
}
#[derive(Clone, Debug)]
pub struct PromptRequest {
text: String,
cwd: Option<PathBuf>,
model: Option<ModelSelector>,
reasoning_effort: Option<ReasoningEffort>,
personality: Option<PersonalitySelector>,
instructions: Option<String>,
timeout: Option<Duration>,
knowledge_policy: Option<KnowledgePolicy>,
structured_output: Option<StructuredOutputRequest>,
tool_selection: Option<ToolSelection>,
}
impl PromptRequest {
pub fn new(text: impl Into<String>) -> Result<Self, KitError> {
let text = text.into();
if text.is_empty() {
return Err(KitError::invalid_input("prompt text must not be empty"));
}
Ok(Self {
text,
cwd: None,
model: None,
reasoning_effort: None,
personality: None,
instructions: None,
timeout: None,
knowledge_policy: None,
structured_output: None,
tool_selection: None,
})
}
pub fn with_cwd(mut self, cwd: PathBuf) -> Result<Self, KitError> {
self.cwd = Some(WorkspaceContext::normalized_relative(&cwd)?);
Ok(self)
}
pub fn with_model(mut self, model: ModelSelector) -> Self {
self.model = Some(model);
self
}
pub fn with_reasoning_effort(mut self, reasoning_effort: ReasoningEffort) -> Self {
self.reasoning_effort = Some(reasoning_effort);
self
}
#[must_use]
pub fn with_personality(mut self, personality: PersonalitySelector) -> Self {
self.personality = Some(personality);
self
}
pub fn with_instructions(mut self, instructions: impl Into<String>) -> Result<Self, KitError> {
let instructions = instructions.into();
if instructions.is_empty() {
return Err(KitError::invalid_input(
"prompt instructions must not be empty",
));
}
self.instructions = Some(instructions);
Ok(self)
}
pub fn with_knowledge_policy(mut self, policy: KnowledgePolicy) -> Result<Self, KitError> {
policy.validate()?;
self.knowledge_policy = Some(policy);
Ok(self)
}
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, KitError> {
if timeout.is_zero() {
return Err(KitError::invalid_input(
"prompt timeout must be greater than zero",
));
}
self.timeout = Some(timeout);
Ok(self)
}
#[must_use]
pub fn with_structured_output(mut self, structured_output: StructuredOutputRequest) -> Self {
self.structured_output = Some(structured_output);
self
}
pub fn with_tool_selection(mut self, selection: ToolSelection) -> Result<Self, KitError> {
selection.validate()?;
self.tool_selection = Some(selection);
Ok(self)
}
pub fn text(&self) -> &str {
&self.text
}
pub fn cwd(&self) -> Option<&Path> {
self.cwd.as_deref()
}
pub fn model(&self) -> Option<&ModelSelector> {
self.model.as_ref()
}
pub fn reasoning_effort(&self) -> Option<&ReasoningEffort> {
self.reasoning_effort.as_ref()
}
pub fn personality(&self) -> Option<&PersonalitySelector> {
self.personality.as_ref()
}
pub fn instructions(&self) -> Option<&str> {
self.instructions.as_deref()
}
pub fn knowledge_policy(&self) -> Option<&KnowledgePolicy> {
self.knowledge_policy.as_ref()
}
pub fn timeout(&self) -> Option<Duration> {
self.timeout
}
pub fn structured_output(&self) -> Option<&StructuredOutputRequest> {
self.structured_output.as_ref()
}
pub fn tool_selection(&self) -> Option<&ToolSelection> {
self.tool_selection.as_ref()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PromptResponse {
output: String,
structured_output: Option<serde_json::Value>,
usage: Option<UsageInfo>,
}
impl PromptResponse {
pub fn new(output: String, usage: Option<UsageInfo>) -> Self {
Self {
output,
structured_output: None,
usage,
}
}
#[must_use]
pub fn from_parts(
output: String,
structured_output: Option<serde_json::Value>,
usage: Option<UsageInfo>,
) -> Self {
Self {
output,
structured_output,
usage,
}
}
pub fn output(&self) -> &str {
&self.output
}
pub fn usage(&self) -> Option<&UsageInfo> {
self.usage.as_ref()
}
pub fn structured_output(&self) -> Option<&serde_json::Value> {
self.structured_output.as_ref()
}
#[must_use]
pub fn into_parts(self) -> (String, Option<serde_json::Value>, Option<UsageInfo>) {
(self.output, self.structured_output, self.usage)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PromptEvent {
OutputDelta {
text: String,
},
Completed(PromptResponse),
}
pub struct PromptStream {
source: Option<PromptSource>,
}
enum PromptSource {
Caller(Pin<Box<dyn Stream<Item = Result<PromptEvent, KitError>> + Send + 'static>>),
Buffered {
admission: PromptAdmission,
fragments: mpsc::Receiver<String>,
terminal: oneshot::Receiver<Result<PromptResponse, KitError>>,
},
}
enum PromptAdmission {
Pending(oneshot::Receiver<()>),
Accepted,
Rejected,
}
pub(crate) struct PromptOutput {
state: Mutex<PromptOutputState>,
}
enum PromptOutputState {
Open {
admission: Option<oneshot::Sender<()>>,
fragments: mpsc::Sender<String>,
terminal: oneshot::Sender<Result<PromptResponse, KitError>>,
},
Closed,
}
impl PromptOutput {
const FRAGMENTS: usize = 64;
const FRAGMENT_BYTES: usize = 16 * 1024;
pub(crate) fn channel() -> (Arc<Self>, PromptStream) {
let (output, stream) = Self::gated_channel();
output.admit();
(output, stream)
}
pub(crate) fn gated_channel() -> (Arc<Self>, PromptStream) {
let (admission, admitted) = oneshot::channel();
let (fragments, receiver) = mpsc::channel(Self::FRAGMENTS);
let (terminal, completion) = oneshot::channel();
(
Arc::new(Self {
state: Mutex::new(PromptOutputState::Open {
admission: Some(admission),
fragments,
terminal,
}),
}),
PromptStream {
source: Some(PromptSource::Buffered {
admission: PromptAdmission::Pending(admitted),
fragments: receiver,
terminal: completion,
}),
},
)
}
pub(crate) fn admit(&self) {
if let PromptOutputState::Open { admission, .. } = &mut *self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
&& let Some(admission) = admission.take()
{
let _ = admission.send(());
}
}
pub(crate) fn fragment(&self, mut text: &str) {
let mut state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let PromptOutputState::Open { fragments, .. } = &*state else {
return;
};
loop {
let mut end = text.len().min(Self::FRAGMENT_BYTES);
while !text.is_char_boundary(end) {
end -= 1;
}
if let Err(error) = fragments.try_send(text[..end].to_owned()) {
let previous = std::mem::replace(&mut *state, PromptOutputState::Closed);
if let PromptOutputState::Open { terminal, .. } = previous {
let failure = match error {
mpsc::error::TrySendError::Full(_) => {
KitError::integration("prompt output observation overflow")
.with_detail(crate::error::ErrorDetail::ResponseLimit {
limit: Self::FRAGMENTS,
})
.with_context(
crate::error::Operation::Prompt,
crate::error::ErrorPhase::Publication,
)
}
mpsc::error::TrySendError::Closed(_) => {
KitError::cancelled("prompt output observer was dropped")
}
};
let _ = terminal.send(Err(failure));
}
return;
}
text = &text[end..];
if text.is_empty() {
return;
}
}
}
pub(crate) fn finish(&self, result: Result<PromptResponse, KitError>) {
let previous = std::mem::replace(
&mut *self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()),
PromptOutputState::Closed,
);
if let PromptOutputState::Open { terminal, .. } = previous {
let _ = terminal.send(result);
}
}
}
impl PromptStream {
pub fn from_stream<S>(stream: S) -> Self
where
S: Stream<Item = Result<PromptEvent, KitError>> + Send + 'static,
{
Self {
source: Some(PromptSource::Caller(Box::pin(stream))),
}
}
pub async fn recv(&mut self) -> Option<Result<PromptEvent, KitError>> {
let item = match self.source.as_mut()? {
PromptSource::Caller(source) => source.next().await,
PromptSource::Buffered {
admission,
fragments,
terminal,
} => {
if let PromptAdmission::Pending(receiver) = admission {
*admission = if receiver.await.is_ok() {
PromptAdmission::Accepted
} else {
PromptAdmission::Rejected
};
}
if matches!(admission, PromptAdmission::Accepted)
&& let Some(text) = fragments.recv().await
{
Some(Ok(PromptEvent::OutputDelta { text }))
} else {
terminal
.await
.ok()
.map(|result| result.map(PromptEvent::Completed))
}
}
};
match item {
Some(Ok(PromptEvent::OutputDelta { text })) => {
Some(Ok(PromptEvent::OutputDelta { text }))
}
Some(terminal) => {
self.source.take();
Some(terminal.map_err(|error| {
error
.at_operation(crate::error::Operation::Prompt)
.in_phase(crate::error::ErrorPhase::Publication)
}))
}
None => {
self.source.take();
Some(Err(KitError::protocol(
"prompt output ended before its terminal response",
)
.with_context(
crate::error::Operation::Prompt,
crate::error::ErrorPhase::Publication,
)
.with_detail(crate::error::ErrorDetail::IncompleteStream)))
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UsageInfo {
input_tokens: u64,
output_tokens: u64,
}
impl UsageInfo {
pub fn new(input_tokens: u64, output_tokens: u64) -> Self {
Self {
input_tokens,
output_tokens,
}
}
pub fn input_tokens(&self) -> u64 {
self.input_tokens
}
pub fn output_tokens(&self) -> u64 {
self.output_tokens
}
}