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
57 changes: 57 additions & 0 deletions .claude/new-task.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Project notes for /new-task

Facts a task needs that the repository cannot tell you.

## A fresh worktree needs the local config copied in

Credentials and local configuration are gitignored, so `git worktree add` does
not bring them. They live in the main checkout. First thing after creating a
worktree:

```bash
cp ../main/.env ../main/sonari.toml .
mkdir -p models && cp ../main/models/silero_vad.onnx models/
```

| File | Holds | Tracked template |
|---|---|---|
| `.env` | Provider keys, database DSN, LiveKit endpoint and secret | `.env.example` |
| `sonari.toml` | Personas, prompts, endpointing parameters | `sonari.toml.example` |
| `models/silero_vad.onnx` | The one model that runs in this process | `scripts/fetch-models.sh` |

Without them the failure is misleading: the harness reports
`ELEVENLABS_API_KEY must be set` or `models.vad.model points at a file that does
not exist`, which reads as "no credentials exist" rather than "they are one
directory up".

Load them into the environment before running anything that talks to a provider:

```bash
set -a; . ./.env; set +a
```

## Commands

| | Command |
|---|---|
| Tests, native | `cargo test -p harness -p speech-runtime -p agent` |
| Everything, including what links only on Linux | `scripts/dev.sh cargo test --workspace` |
| Lint as CI does | `scripts/dev.sh cargo clippy --workspace --all-targets -- -D warnings` |
| The full stack | `docker compose up -d` |

`app` and anything pulling in `libwebrtc` link only on Linux
(`docs/architecture.md` §10), so they go through `scripts/dev.sh`. Provider-level
crates, `speech-runtime`, `agent` and `harness` build natively on Windows, which
is the fast loop.

## Evaluation harness

```bash
cargo run --release -p harness -- generate # build the clip set
cargo run --release -p harness -- run evals/set.jsonl --epochs 3 # components
scripts/dev.sh cargo run --release -p harness --features live -- \
run evals/set.jsonl --live # the running service
```

`--live` needs the stack up and `SONARI_BASE_URL` set. Timings mean nothing from
a debug build.
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ jobs:
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Build
run: cargo build --workspace
# The harness's own arithmetic — normalisation, edit distance,
# percentiles, batch behaviour, clip assembly. Its dependencies are
# already compiled by the step above, so this costs one link. Tests that
# need credentials or a running stack skip themselves.
- name: Test the evaluation harness
run: cargo test -p harness

compose:
runs-on: ubuntu-latest
Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ database DSN, and where LiveKit is.
| `crates/probe` | Joins a call over WebRTC as a caller that is not a person |
| `crates/api`, `crates/app` | HTTP surface and the composition root |

`docs/prd.md` says what it is for. `docs/architecture.md` describes how it fits
`docs/product.md` says what it is for. `docs/architecture.md` describes how it fits
together. `docs/adr/` records why, one decision per file, including the ones
that were reversed. `crates/harness/OPTIMISATION-LOG.md` holds every latency
figure that has been measured.
Expand Down
51 changes: 43 additions & 8 deletions crates/agent/application/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ pub struct ChatCommand {
pub user_message: String,
}

/// A finished turn, with the two timings only the streaming loop can see.
///
/// The reply is what the caller wanted; the timings are two of ADR-0010's eight
/// markers, and they exist nowhere else — by the time the text is assembled, the
/// moment the first token arrived has passed.
#[derive(Debug, Clone)]
pub struct ChatOutcome {
pub reply_text: String,
/// When the first token arrived, as epoch milliseconds — not an offset.
/// An offset needs an anchor, and the anchor is in another crate.
pub first_token_at_ms: Option<i64>,
pub first_sentence_at_ms: Option<i64>,
}

#[derive(Debug, Clone, Serialize)]
pub struct AdminConfigView {
pub provider_key: String,
Expand Down Expand Up @@ -139,12 +153,12 @@ trait AgentUseCases: Send + Sync {
async fn create_session(&self, command: CreateSessionCommand) -> AppResult<AgentSession>;
async fn get_session(&self, session_id: &str) -> AppResult<AgentSession>;
async fn generate_welcome_message(&self, session_id: &str) -> AppResult<String>;
async fn chat_once(&self, command: ChatCommand) -> AppResult<String>;
async fn chat_once(&self, command: ChatCommand) -> AppResult<ChatOutcome>;
}

#[async_trait]
pub trait AgentRuntimeUseCases: Send + Sync {
async fn chat_once(&self, command: ChatCommand) -> AppResult<String>;
async fn chat_once(&self, command: ChatCommand) -> AppResult<ChatOutcome>;
/// 生成开场欢迎语(server-initiated turn);进程内编排时由 worker 起会话后调用。
async fn generate_welcome_message(&self, agent_session_id: &str) -> AppResult<String>;
}
Expand Down Expand Up @@ -240,7 +254,7 @@ where
Ok(content)
}

async fn chat_once(&self, command: ChatCommand) -> AppResult<String> {
async fn chat_once(&self, command: ChatCommand) -> AppResult<ChatOutcome> {
let session = self.get_session(&command.session_id).await?;
let provider = self
.require_provider_config(ProviderKey::Conversation)
Expand Down Expand Up @@ -279,7 +293,11 @@ where
turn_number,
)
.await?;
Ok(response.content)
Ok(ChatOutcome {
reply_text: response.content,
first_token_at_ms: response.first_token_at_ms,
first_sentence_at_ms: response.first_sentence_at_ms,
})
}
}

Expand All @@ -296,9 +314,19 @@ async fn collect_reply(
let mut content = String::new();
let mut usage = crate::ports::LlmUsage::default();
let mut tool_calls = Vec::new();
let mut first_token_at_ms = None;
let mut first_sentence_at_ms = None;
while let Some(delta) = stream.next().await {
match delta? {
crate::ports::LlmDelta::Token(token) => content.push_str(&token),
crate::ports::LlmDelta::Token(token) => {
if first_token_at_ms.is_none() {
first_token_at_ms = Some(chrono::Utc::now().timestamp_millis());
}
content.push_str(&token);
if first_sentence_at_ms.is_none() && ends_a_sentence(&content) {
first_sentence_at_ms = Some(chrono::Utc::now().timestamp_millis());
}
}
crate::ports::LlmDelta::ToolCall(call) => tool_calls.push(call),
crate::ports::LlmDelta::Done(reported) => usage = reported,
}
Expand All @@ -316,9 +344,18 @@ async fn collect_reply(
content,
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
first_token_at_ms,
first_sentence_at_ms,
})
}

/// Cheap test for a completed sentence, used only to time when one exists.
/// Synthesis can begin at that point, so it is the earliest a reply could be
/// spoken.
fn ends_a_sentence(text: &str) -> bool {
text.trim_end().ends_with(['.', '!', '?', '。', '!', '?'])
}

/// Retained for the validation it encodes, which its test pins down; the admin
/// surface that called it is gone.
#[cfg(test)]
Expand All @@ -337,8 +374,6 @@ fn valid_agent_caller(caller: &AgentCallerIdentity) -> bool {
}
}

#[async_trait]
#[async_trait]
#[async_trait]
impl<P, T, PP, S, M, U, G, C, I, K> AgentRuntimeUseCases
for AgentService<P, T, PP, S, M, U, G, C, I, K>
Expand All @@ -354,7 +389,7 @@ where
I: IdGenerator + Send + Sync,
K: Clock + Send + Sync,
{
async fn chat_once(&self, command: ChatCommand) -> AppResult<String> {
async fn chat_once(&self, command: ChatCommand) -> AppResult<ChatOutcome> {
AgentUseCases::chat_once(self, command).await
}

Expand Down
12 changes: 11 additions & 1 deletion crates/agent/ports/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,21 @@ pub struct LlmCompletionRequest {
pub tools: Vec<ToolDefinition>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
// No `Eq`: the timings are floating point.
#[derive(Debug, Clone, PartialEq)]
pub struct LlmCompletionResponse {
pub content: String,
pub prompt_tokens: i32,
pub completion_tokens: i32,
/// When the first token arrived, as epoch milliseconds.
///
/// Recorded here because this is the only place that sees the stream, and
/// absolute because two of ADR-0010's markers are derived from it in another
/// crate — an offset would need an anchor, and the anchor would be a guess.
pub first_token_at_ms: Option<i64>,
/// When the first complete sentence existed — what synthesis can start on,
/// and therefore the earliest the reply could begin to be spoken.
pub first_sentence_at_ms: Option<i64>,
}

/// A tool the model may call. Declared per persona and dispatched by the
Expand Down
2 changes: 1 addition & 1 deletion crates/agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub use adapters::postgres::{
PostgresPartnerConversationPromptOverrideRepository, PostgresPromptTemplateRepository,
};
pub use application::{
AgentDependencies, AgentRuntimeUseCases, AgentService, ChatCommand,
AgentDependencies, AgentRuntimeUseCases, AgentService, ChatCommand, ChatOutcome,
PartnerConversationPromptConfigView, UpdateAdminConfigCommand,
UpdatePartnerConversationPromptConfigCommand,
};
Expand Down
1 change: 1 addition & 0 deletions crates/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ base64 = "0.22.1"
bytes = "1"
uuid = { version = "1", features = ["v4"] }
call = { path = "../call/control" }
character-context = { path = "../character-context" }
call-log-contract = { path = "../call/log-contract" }
call-execution = { path = "../call/execution" }
rtc-control-contract = { path = "../call/rtc/control-contract" }
Expand Down
Loading
Loading