@anthusai/papyrus 0.0.0 → 1.0.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/amplify/auth/resource.js +61 -0
- package/amplify/auth/resource.ts +65 -0
- package/amplify/data/resource.js +2037 -0
- package/amplify/data/resource.ts +2148 -0
- package/amplify/data/schema.js +1850 -0
- package/amplify/data/schema.ts +1953 -0
- package/amplify/functions/assignment-action/handler.ts +1416 -0
- package/amplify/functions/assignment-action/resource.js +8 -0
- package/amplify/functions/assignment-action/resource.ts +9 -0
- package/amplify/functions/category-action/handler.ts +2259 -0
- package/amplify/functions/category-action/resource.js +8 -0
- package/amplify/functions/category-action/resource.ts +9 -0
- package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
- package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
- package/amplify/functions/console-chat-responder/Dockerfile +19 -0
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
- package/amplify/functions/console-chat-responder/resource.js +153 -0
- package/amplify/functions/console-chat-responder/resource.ts +179 -0
- package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
- package/amplify/functions/email-submission-processor/handler.py +46 -0
- package/amplify/functions/email-submission-processor/requirements.txt +2 -0
- package/amplify/functions/email-submission-processor/resource.js +56 -0
- package/amplify/functions/email-submission-processor/resource.ts +69 -0
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
- package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
- package/amplify/functions/knowledge-query/handler.py +1 -0
- package/amplify/functions/knowledge-query/requirements.txt +3 -0
- package/amplify/functions/knowledge-query/resource.js +50 -0
- package/amplify/functions/knowledge-query/resource.ts +61 -0
- package/amplify/functions/manage-user-role/handler.ts +799 -0
- package/amplify/functions/manage-user-role/resource.js +8 -0
- package/amplify/functions/manage-user-role/resource.ts +9 -0
- package/amplify/functions/model-attachment-upload/handler.ts +387 -0
- package/amplify/functions/model-attachment-upload/resource.js +8 -0
- package/amplify/functions/model-attachment-upload/resource.ts +9 -0
- package/amplify/functions/newsroom-summary/handler.ts +321 -0
- package/amplify/functions/newsroom-summary/resource.js +8 -0
- package/amplify/functions/newsroom-summary/resource.ts +9 -0
- package/amplify/functions/procedure-action/handler.ts +621 -0
- package/amplify/functions/procedure-action/resource.js +8 -0
- package/amplify/functions/procedure-action/resource.ts +9 -0
- package/amplify/functions/reader-settings/handler.ts +322 -0
- package/amplify/functions/reader-settings/resource.js +8 -0
- package/amplify/functions/reader-settings/resource.ts +9 -0
- package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
- package/amplify/functions/ses-inbound-receive/resource.js +8 -0
- package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
- package/amplify/functions/shared/amplify-secrets.ts +65 -0
- package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
- package/amplify/functions/shared/email-mime-intake.ts +196 -0
- package/amplify/functions/shared/email-submission.ts +415 -0
- package/amplify/functions/shared/inbound-email-intake.ts +37 -0
- package/amplify/functions/shared/lambda-data-client.ts +21 -0
- package/amplify/functions/shared/model-payloads.ts +186 -0
- package/amplify/functions/shared/python-bundle.js +115 -0
- package/amplify/functions/shared/python-bundle.ts +141 -0
- package/amplify/functions/shared/slack-events.ts +111 -0
- package/amplify/functions/slack-delivery/handler.py +112 -0
- package/amplify/functions/slack-delivery/requirements.txt +2 -0
- package/amplify/functions/slack-delivery/resource.js +38 -0
- package/amplify/functions/slack-delivery/resource.ts +51 -0
- package/amplify/functions/slack-events/handler.ts +155 -0
- package/amplify/functions/slack-events/resource.js +11 -0
- package/amplify/functions/slack-events/resource.ts +12 -0
- package/amplify/inbound-email/stack.js +46 -0
- package/amplify/inbound-email/stack.ts +61 -0
- package/amplify/package.json +3 -0
- package/amplify/site-backend-config.js +1 -0
- package/amplify/site-backend-config.ts +30 -0
- package/amplify/site-backend.js +587 -0
- package/amplify/site-backend.ts +704 -0
- package/amplify/storage/resource.js +18 -0
- package/amplify/storage/resource.ts +19 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
- package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
- package/app/[year]/[month]/[day]/page.tsx +22 -0
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
- package/app/api/archive/editions/route.ts +18 -0
- package/app/api/media/[...path]/route.ts +35 -0
- package/app/api/revalidate/route.ts +94 -0
- package/app/archive/page.tsx +24 -0
- package/app/articles/[slug]/page.tsx +35 -0
- package/app/globals.css +10329 -0
- package/app/layout.tsx +146 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
- package/app/newsroom/[section]/page.tsx +47 -0
- package/app/newsroom/forum/[threadId]/page.tsx +35 -0
- package/app/newsroom/insights/[threadId]/page.tsx +22 -0
- package/app/newsroom/insights/page.tsx +14 -0
- package/app/newsroom/layout.tsx +12 -0
- package/app/newsroom/page.tsx +31 -0
- package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
- package/app/page.tsx +272 -0
- package/app/settings/page.tsx +14 -0
- package/app/tailwind.css +65 -0
- package/bin/papyrus-app.mjs +10 -0
- package/bin/papyrus-infra.mjs +39 -0
- package/components/ai-elements/conversation.tsx +168 -0
- package/components/ai-elements/message.tsx +360 -0
- package/components/ai-elements/model-selector.tsx +30 -0
- package/components/ai-elements/prompt-input.tsx +1461 -0
- package/components/ai-elements/shimmer.tsx +76 -0
- package/components/ai-elements/suggestion.tsx +54 -0
- package/components/ai-elements/tool.tsx +173 -0
- package/components/amplify-client-provider.tsx +140 -0
- package/components/archive-grid.tsx +89 -0
- package/components/archive-shell.tsx +41 -0
- package/components/article-video.tsx +47 -0
- package/components/blog-page-background.tsx +20 -0
- package/components/dev-sandbox-editor-sign-in.tsx +60 -0
- package/components/news-desk-client-provider.tsx +211 -0
- package/components/news-desk-page.tsx +119 -0
- package/components/news-desk-taxonomy-client.ts +3684 -0
- package/components/newspaper.tsx +1929 -0
- package/components/newsroom-assignments-view.tsx +664 -0
- package/components/newsroom-ops-overview.tsx +226 -0
- package/components/newsroom-ops-shell.tsx +319 -0
- package/components/newsroom-references-view.tsx +598 -0
- package/components/newsroom-topics-view.tsx +425 -0
- package/components/papyrus-console-shell.tsx +1912 -0
- package/components/pictogram-figure.tsx +65 -0
- package/components/presentation-footer.tsx +146 -0
- package/components/reader-auth-control.tsx +162 -0
- package/components/reader-auth-state.ts +122 -0
- package/components/reader-settings.ts +332 -0
- package/components/reader-theme-control.tsx +85 -0
- package/components/reference-source-preview.tsx +163 -0
- package/components/settings-page.tsx +239 -0
- package/components/topic-steering-workspace.tsx +18063 -0
- package/components/ui/badge.tsx +30 -0
- package/components/ui/button-group.tsx +87 -0
- package/components/ui/button.tsx +58 -0
- package/components/ui/card.tsx +32 -0
- package/components/ui/command.tsx +194 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/dropdown-menu.tsx +266 -0
- package/components/ui/hover-card.tsx +51 -0
- package/components/ui/input-group.tsx +158 -0
- package/components/ui/input.tsx +20 -0
- package/components/ui/scroll-area.tsx +16 -0
- package/components/ui/select.tsx +199 -0
- package/components/ui/separator.tsx +23 -0
- package/components/ui/sheet.tsx +92 -0
- package/components/ui/spinner.tsx +10 -0
- package/components/ui/tabs.tsx +113 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/components/use-resolved-papyrus-theme.ts +47 -0
- package/infra/amplify-app-shell.js +109 -0
- package/infra/amplify-app-shell.ts +155 -0
- package/infra/index.js +12 -0
- package/lib/amplify-outputs-guard.ts +24 -0
- package/lib/amplify-outputs-path.ts +16 -0
- package/lib/amplify-server-runtime.ts +63 -0
- package/lib/archive-data.ts +30 -0
- package/lib/archive-types.ts +15 -0
- package/lib/articles.ts +320 -0
- package/lib/cached-content-repository.ts +89 -0
- package/lib/category-dashboard.ts +37 -0
- package/lib/category-repository.ts +1907 -0
- package/lib/console-chat-client.ts +1316 -0
- package/lib/console-web-context.ts +118 -0
- package/lib/content-load-timing.ts +23 -0
- package/lib/content-repository.ts +40 -0
- package/lib/content-types.ts +104 -0
- package/lib/define-site.js +3 -0
- package/lib/define-site.ts +21 -0
- package/lib/demo-amplify-outputs.ts +15 -0
- package/lib/dev-sandbox-editor-auth.ts +18 -0
- package/lib/doctrine.ts +128 -0
- package/lib/edition-routes.ts +155 -0
- package/lib/edition-sections.ts +111 -0
- package/lib/empty-edition-layout-plan.ts +109 -0
- package/lib/excerpts.ts +8 -0
- package/lib/graphql-content-repository.ts +986 -0
- package/lib/image-url.ts +10 -0
- package/lib/layout-plan.ts +981 -0
- package/lib/layout-scenarios.ts +564 -0
- package/lib/markus-body.ts +168 -0
- package/lib/markus-ir.ts +235 -0
- package/lib/markus-projection.ts +243 -0
- package/lib/markus-to-article.ts +93 -0
- package/lib/news-desk-session.ts +160 -0
- package/lib/newspaper-layout.ts +3459 -0
- package/lib/newsroom-assignments.ts +173 -0
- package/lib/newsroom-base-path.ts +48 -0
- package/lib/newsroom-card-layout.ts +102 -0
- package/lib/newsroom-category-drilldown.ts +133 -0
- package/lib/newsroom-demo-dashboard.ts +267 -0
- package/lib/newsroom-demo-profile.ts +46 -0
- package/lib/newsroom-forum-routes.ts +153 -0
- package/lib/newsroom-index-filters.ts +202 -0
- package/lib/newsroom-knowledge-query-request.ts +51 -0
- package/lib/newsroom-list-selection.ts +42 -0
- package/lib/newsroom-nav.ts +42 -0
- package/lib/newsroom-references.ts +116 -0
- package/lib/newsroom-sections.ts +52 -0
- package/lib/newsroom-topics.ts +118 -0
- package/lib/papyrus-object-kinds.ts +24 -0
- package/lib/papyrus-web-locations.ts +480 -0
- package/lib/pilobol-sample.ts +167 -0
- package/lib/presentation-footer.ts +92 -0
- package/lib/pretext-layout.ts +179 -0
- package/lib/public-placeholder-config.ts +102 -0
- package/lib/publication-items.ts +181 -0
- package/lib/reader-route-config.ts +1 -0
- package/lib/reader-static-params.ts +56 -0
- package/lib/reader-storage-url.ts +63 -0
- package/lib/reference-display.ts +74 -0
- package/lib/reference-policy.ts +97 -0
- package/lib/reference-source-preview.ts +175 -0
- package/lib/renderer-config.ts +50 -0
- package/lib/renderer.ts +87 -0
- package/lib/reporting-story-budget.ts +638 -0
- package/lib/semantic-graph.ts +554 -0
- package/lib/site-brand-tenant.ts +41 -0
- package/lib/site-brand.ts +179 -0
- package/lib/site-renderer.ts +17 -0
- package/lib/site-stack.ts +195 -0
- package/lib/themed-image.ts +30 -0
- package/lib/utils.ts +6 -0
- package/middleware.ts +78 -0
- package/package.json +296 -1
- package/publications/anth_us/brand.ts +68 -0
- package/publications/anth_us/theme.css +66 -0
- package/publications/papyrus/theme.css +11 -0
- package/publications/pilobol_us/brand.ts +37 -0
- package/publications/pilobol_us/theme.css +140 -0
- package/publications/threat_intelligence/blog-defense/graph.ts +636 -0
- package/publications/threat_intelligence/blog-defense/layout.ts +934 -0
- package/publications/threat_intelligence/blog-defense/page-background.tsx +590 -0
- package/publications/threat_intelligence/brand.ts +40 -0
- package/publications/threat_intelligence/pictograms/art.tsx +608 -0
- package/publications/threat_intelligence/pictograms/figure.tsx +82 -0
- package/publications/threat_intelligence/pictograms/registry.ts +30 -0
- package/publications/threat_intelligence/pictograms/system.tsx +305 -0
- package/publications/threat_intelligence/seed/seed-edition-content.json +629 -0
- package/publications/threat_intelligence/theme.css +1440 -0
- package/renderers/markus/index.ts +25 -0
- package/renderers/markus/stubs.tsx +33 -0
- package/renderers/pretext/article-page.tsx +174 -0
- package/renderers/pretext/index.ts +30 -0
- package/renderers/pretext/layouts.ts +16 -0
- package/renderers/pretext/presentation-shell.tsx +477 -0
- package/routes.manifest.json +182 -0
- package/scripts/fixtures/pilobol-sample.ast.json +234 -0
- package/src/backend.js +1 -0
- package/src/backend.ts +3 -0
- package/src/sync.mjs +87 -0
- package/src/with-papyrus.d.mts +2 -0
- package/src/with-papyrus.mjs +47 -0
|
@@ -0,0 +1,3242 @@
|
|
|
1
|
+
use anyhow::{Context, Result, anyhow};
|
|
2
|
+
use aws_config::BehaviorVersion;
|
|
3
|
+
use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider};
|
|
4
|
+
use aws_sdk_dynamodb::Client as DynamoClient;
|
|
5
|
+
use aws_sdk_dynamodb::types::AttributeValue;
|
|
6
|
+
use aws_sdk_ssm::Client as SsmClient;
|
|
7
|
+
use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings, sign};
|
|
8
|
+
use aws_sigv4::sign::v4;
|
|
9
|
+
use chrono::{DateTime, Duration, Utc};
|
|
10
|
+
use futures_util::StreamExt;
|
|
11
|
+
use lambda_runtime::{Error, LambdaEvent, run, service_fn};
|
|
12
|
+
use serde::{Deserialize, Serialize};
|
|
13
|
+
use serde_json::{Value, json};
|
|
14
|
+
use sha2::{Digest, Sha256};
|
|
15
|
+
use std::collections::{HashMap, HashSet};
|
|
16
|
+
use std::fs;
|
|
17
|
+
use std::io::ErrorKind;
|
|
18
|
+
use std::path::{Path, PathBuf};
|
|
19
|
+
use std::sync::Arc;
|
|
20
|
+
use std::time::SystemTime;
|
|
21
|
+
use tokio::io::AsyncWriteExt;
|
|
22
|
+
use tokio::process::Command;
|
|
23
|
+
use tokio::time::{Duration as TokioDuration, timeout};
|
|
24
|
+
use tracing::{error, info, warn};
|
|
25
|
+
use uuid::Uuid;
|
|
26
|
+
|
|
27
|
+
const DEFAULT_RESPONSE_TARGET: &str = "cloud";
|
|
28
|
+
const DEFAULT_MODEL: &str = "gpt-5-nano";
|
|
29
|
+
const SUPPORTED_CONSOLE_MODELS: [&str; 5] = [
|
|
30
|
+
"gpt-5.5",
|
|
31
|
+
"gpt-5.4",
|
|
32
|
+
"gpt-5.4-mini",
|
|
33
|
+
"gpt-5.4-nano",
|
|
34
|
+
"gpt-5-nano",
|
|
35
|
+
];
|
|
36
|
+
const MESSAGE_KIND_CHAT_TURN: &str = "console_chat_turn";
|
|
37
|
+
const MESSAGE_KIND_WEB_CONTEXT: &str = "console_web_context";
|
|
38
|
+
const MESSAGE_KIND_TOOL_CALL: &str = "console_tool_call";
|
|
39
|
+
const MESSAGE_KIND_TOOL_RESULT: &str = "console_tool_result";
|
|
40
|
+
const MESSAGE_DOMAIN_CONVERSATION: &str = "conversation";
|
|
41
|
+
const NEWSROOM_FEED_CONSOLE_CHAT: &str = "consoleChat";
|
|
42
|
+
const CHAT_DETAIL_LAYER: &str = "chat_detail";
|
|
43
|
+
const EXPLICIT_SEARCH: &str = "explicit";
|
|
44
|
+
const CONTEXT_CACHE_SCHEMA_VERSION: u32 = 1;
|
|
45
|
+
const STATIC_PROMPT_CACHE_SCHEMA_VERSION: u32 = 1;
|
|
46
|
+
const STREAM_FLUSH_INTERVAL_MS: i64 = 200;
|
|
47
|
+
const STREAM_FLUSH_CHARS: usize = 96;
|
|
48
|
+
const DEFAULT_STATIC_PROMPT_CACHE_TTL_SECONDS: i64 = 900;
|
|
49
|
+
const DEFAULT_EXECUTE_TACTUS_TIMEOUT_SECONDS: u64 = 30;
|
|
50
|
+
const SHARED_OPENAI_API_KEY_SSM_PARAM: &str = "/amplify/shared/papyrus/OPENAI_API_KEY";
|
|
51
|
+
const LOCAL_RESPONDER_INPUT_ENV: &str = "PAPYRUS_LOCAL_RESPONDER_INPUT_JSON";
|
|
52
|
+
|
|
53
|
+
#[tokio::main]
|
|
54
|
+
async fn main() -> Result<(), Error> {
|
|
55
|
+
tracing_subscriber::fmt().without_time().init();
|
|
56
|
+
let local_input_path = optional_env(LOCAL_RESPONDER_INPUT_ENV);
|
|
57
|
+
let aws_config = aws_config::defaults(BehaviorVersion::latest()).load().await;
|
|
58
|
+
let dynamo = DynamoClient::new(&aws_config);
|
|
59
|
+
let ssm = SsmClient::new(&aws_config);
|
|
60
|
+
let config = AppConfig::from_env(local_input_path.is_none());
|
|
61
|
+
let openai_api_key = load_openai_api_key(&ssm).await?;
|
|
62
|
+
let region = aws_config
|
|
63
|
+
.region()
|
|
64
|
+
.map(|value| value.as_ref().to_string())
|
|
65
|
+
.or_else(|| appsync_region_from_endpoint(&config.graphql_endpoint))
|
|
66
|
+
.ok_or_else(|| anyhow!("Unable to determine AWS region for AppSync IAM signing"))?;
|
|
67
|
+
let credentials_provider = aws_config
|
|
68
|
+
.credentials_provider()
|
|
69
|
+
.ok_or_else(|| anyhow!("AWS credentials provider is unavailable for AppSync IAM signing"))?;
|
|
70
|
+
let state = Arc::new(AppState {
|
|
71
|
+
config,
|
|
72
|
+
dynamo,
|
|
73
|
+
http: reqwest::Client::new(),
|
|
74
|
+
openai_api_key,
|
|
75
|
+
aws_region: region,
|
|
76
|
+
credentials_provider,
|
|
77
|
+
});
|
|
78
|
+
if let Some(path) = local_input_path {
|
|
79
|
+
let payload = fs::read_to_string(&path)
|
|
80
|
+
.with_context(|| format!("read local responder input payload {path}"))?;
|
|
81
|
+
let input: LocalResponderInput = serde_json::from_str(&payload)
|
|
82
|
+
.with_context(|| format!("parse local responder input JSON {path}"))?;
|
|
83
|
+
let response = run_local_responder(&state, input).await?;
|
|
84
|
+
println!("{}", serde_json::to_string(&response)?);
|
|
85
|
+
return Ok(());
|
|
86
|
+
}
|
|
87
|
+
run(service_fn(move |event| {
|
|
88
|
+
let state = Arc::clone(&state);
|
|
89
|
+
async move { handler(event, state).await }
|
|
90
|
+
}))
|
|
91
|
+
.await
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[derive(Clone, Debug)]
|
|
95
|
+
struct AppConfig {
|
|
96
|
+
message_table: String,
|
|
97
|
+
thread_table: String,
|
|
98
|
+
thread_sequence_index: String,
|
|
99
|
+
response_target: String,
|
|
100
|
+
model: String,
|
|
101
|
+
graphql_endpoint: String,
|
|
102
|
+
cache_root: PathBuf,
|
|
103
|
+
execute_tactus_runner: PathBuf,
|
|
104
|
+
execute_tactus_timeout_seconds: u64,
|
|
105
|
+
static_prompt_cache_ttl_seconds: i64,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
impl AppConfig {
|
|
109
|
+
fn from_env(require_dynamo: bool) -> Self {
|
|
110
|
+
Self {
|
|
111
|
+
message_table: if require_dynamo {
|
|
112
|
+
required_env("PAPYRUS_MESSAGE_TABLE_NAME")
|
|
113
|
+
} else {
|
|
114
|
+
optional_env("PAPYRUS_MESSAGE_TABLE_NAME").unwrap_or_default()
|
|
115
|
+
},
|
|
116
|
+
thread_table: if require_dynamo {
|
|
117
|
+
required_env("PAPYRUS_MESSAGE_THREAD_TABLE_NAME")
|
|
118
|
+
} else {
|
|
119
|
+
optional_env("PAPYRUS_MESSAGE_THREAD_TABLE_NAME").unwrap_or_default()
|
|
120
|
+
},
|
|
121
|
+
thread_sequence_index: env_or(
|
|
122
|
+
"PAPYRUS_MESSAGE_THREAD_SEQUENCE_INDEX_NAME",
|
|
123
|
+
"messagesByThreadSequence",
|
|
124
|
+
),
|
|
125
|
+
response_target: env_or("PAPYRUS_CONSOLE_RESPONSE_TARGET", DEFAULT_RESPONSE_TARGET),
|
|
126
|
+
model: env_or("PAPYRUS_CONSOLE_MODEL", DEFAULT_MODEL),
|
|
127
|
+
graphql_endpoint: required_env("PAPYRUS_GRAPHQL_ENDPOINT"),
|
|
128
|
+
cache_root: PathBuf::from(env_or(
|
|
129
|
+
"PAPYRUS_CONSOLE_CONTEXT_CACHE_ROOT",
|
|
130
|
+
"/tmp/papyrus-console/thread-context",
|
|
131
|
+
)),
|
|
132
|
+
execute_tactus_runner: PathBuf::from(env_or(
|
|
133
|
+
"PAPYRUS_EXECUTE_TACTUS_RUNNER",
|
|
134
|
+
"/opt/papyrus/execute_tactus_runner.py",
|
|
135
|
+
)),
|
|
136
|
+
execute_tactus_timeout_seconds: optional_env("PAPYRUS_EXECUTE_TACTUS_TIMEOUT_SECONDS")
|
|
137
|
+
.and_then(|value| value.parse::<u64>().ok())
|
|
138
|
+
.filter(|value| *value > 0)
|
|
139
|
+
.unwrap_or(DEFAULT_EXECUTE_TACTUS_TIMEOUT_SECONDS),
|
|
140
|
+
static_prompt_cache_ttl_seconds: optional_env("PAPYRUS_CONSOLE_STATIC_CONTEXT_TTL_SECONDS")
|
|
141
|
+
.and_then(|value| value.parse::<i64>().ok())
|
|
142
|
+
.filter(|value| *value > 0)
|
|
143
|
+
.unwrap_or(DEFAULT_STATIC_PROMPT_CACHE_TTL_SECONDS),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#[derive(Debug, Clone, Deserialize)]
|
|
149
|
+
#[serde(rename_all = "camelCase")]
|
|
150
|
+
struct LocalResponderInput {
|
|
151
|
+
thread_id: String,
|
|
152
|
+
message_id: String,
|
|
153
|
+
content: String,
|
|
154
|
+
#[serde(default)]
|
|
155
|
+
sequence_number: i64,
|
|
156
|
+
#[serde(default)]
|
|
157
|
+
created_at: String,
|
|
158
|
+
#[serde(default)]
|
|
159
|
+
metadata: Value,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async fn run_local_responder(state: &AppState, input: LocalResponderInput) -> Result<Value> {
|
|
163
|
+
let created_at = if input.created_at.trim().is_empty() {
|
|
164
|
+
now_iso()
|
|
165
|
+
} else {
|
|
166
|
+
input.created_at.trim().to_string()
|
|
167
|
+
};
|
|
168
|
+
let message = ChatMessage {
|
|
169
|
+
id: input.message_id,
|
|
170
|
+
thread_id: input.thread_id,
|
|
171
|
+
role: "USER".to_string(),
|
|
172
|
+
message_kind: MESSAGE_KIND_CHAT_TURN.to_string(),
|
|
173
|
+
message_type: "MESSAGE".to_string(),
|
|
174
|
+
content: input.content,
|
|
175
|
+
response_target: state.config.response_target.clone(),
|
|
176
|
+
response_status: "PENDING".to_string(),
|
|
177
|
+
sequence_number: input.sequence_number.max(1),
|
|
178
|
+
created_at,
|
|
179
|
+
metadata: input.metadata,
|
|
180
|
+
};
|
|
181
|
+
if !message.should_handle(&state.config.response_target) {
|
|
182
|
+
return Ok(json!({ "ok": false, "reason": "message_not_handleable" }));
|
|
183
|
+
}
|
|
184
|
+
answer_local_message(state, &message).await?;
|
|
185
|
+
Ok(json!({ "ok": true, "messageId": message.id, "threadId": message.thread_id }))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#[derive(Clone)]
|
|
189
|
+
struct AppState {
|
|
190
|
+
config: AppConfig,
|
|
191
|
+
dynamo: DynamoClient,
|
|
192
|
+
http: reqwest::Client,
|
|
193
|
+
openai_api_key: String,
|
|
194
|
+
aws_region: String,
|
|
195
|
+
credentials_provider: SharedCredentialsProvider,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
199
|
+
struct StaticPromptDocSummary {
|
|
200
|
+
id: String,
|
|
201
|
+
title: String,
|
|
202
|
+
summary: String,
|
|
203
|
+
namespace: String,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
207
|
+
struct StaticPromptContext {
|
|
208
|
+
schema_version: u32,
|
|
209
|
+
graphql_endpoint: String,
|
|
210
|
+
generated_at: String,
|
|
211
|
+
expires_at_epoch: i64,
|
|
212
|
+
publication_mission: String,
|
|
213
|
+
publication_policy: String,
|
|
214
|
+
docs_index: Vec<StaticPromptDocSummary>,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async fn handler(event: LambdaEvent<Value>, state: Arc<AppState>) -> Result<Value, Error> {
|
|
218
|
+
let records = event
|
|
219
|
+
.payload
|
|
220
|
+
.get("Records")
|
|
221
|
+
.and_then(Value::as_array)
|
|
222
|
+
.cloned()
|
|
223
|
+
.unwrap_or_default();
|
|
224
|
+
let mut processed = 0_u32;
|
|
225
|
+
let mut skipped = 0_u32;
|
|
226
|
+
let mut failures: Vec<Value> = Vec::new();
|
|
227
|
+
|
|
228
|
+
for record in records {
|
|
229
|
+
let sequence = record
|
|
230
|
+
.pointer("/dynamodb/SequenceNumber")
|
|
231
|
+
.and_then(Value::as_str)
|
|
232
|
+
.or_else(|| record.get("eventID").and_then(Value::as_str))
|
|
233
|
+
.unwrap_or_default()
|
|
234
|
+
.to_string();
|
|
235
|
+
match process_record(&record, &state).await {
|
|
236
|
+
Ok(RecordOutcome::Processed) => processed += 1,
|
|
237
|
+
Ok(RecordOutcome::Skipped) => skipped += 1,
|
|
238
|
+
Err(error) => {
|
|
239
|
+
error!(sequence, error = %format_error_chain(&error), "failed to process console chat stream record");
|
|
240
|
+
if !sequence.is_empty() {
|
|
241
|
+
failures.push(json!({ "itemIdentifier": sequence }));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
Ok(json!({
|
|
248
|
+
"processed": processed,
|
|
249
|
+
"skipped": skipped,
|
|
250
|
+
"batchItemFailures": failures,
|
|
251
|
+
}))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
enum RecordOutcome {
|
|
255
|
+
Processed,
|
|
256
|
+
Skipped,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async fn process_record(record: &Value, state: &AppState) -> Result<RecordOutcome> {
|
|
260
|
+
if record.get("eventName").and_then(Value::as_str) != Some("INSERT") {
|
|
261
|
+
return Ok(RecordOutcome::Skipped);
|
|
262
|
+
}
|
|
263
|
+
let image = record
|
|
264
|
+
.pointer("/dynamodb/NewImage")
|
|
265
|
+
.and_then(Value::as_object)
|
|
266
|
+
.ok_or_else(|| anyhow!("DynamoDB stream record did not include NewImage"))?;
|
|
267
|
+
let Some(message) = ChatMessage::from_stream_image(image)? else {
|
|
268
|
+
return Ok(RecordOutcome::Skipped);
|
|
269
|
+
};
|
|
270
|
+
if !message.should_handle(&state.config.response_target) {
|
|
271
|
+
return Ok(RecordOutcome::Skipped);
|
|
272
|
+
}
|
|
273
|
+
info!(
|
|
274
|
+
message_id = message.id,
|
|
275
|
+
thread_id = message.thread_id,
|
|
276
|
+
sequence = message.sequence_number,
|
|
277
|
+
"processing console chat message"
|
|
278
|
+
);
|
|
279
|
+
if !claim_message(state, &message).await? {
|
|
280
|
+
return Ok(RecordOutcome::Skipped);
|
|
281
|
+
}
|
|
282
|
+
publish_message_status(state, &message, "RUNNING", None).await?;
|
|
283
|
+
let lock_owner = format!(
|
|
284
|
+
"{}:{}",
|
|
285
|
+
std::env::var("AWS_LAMBDA_FUNCTION_NAME")
|
|
286
|
+
.unwrap_or_else(|_| "papyrus-console-chat-responder".to_string()),
|
|
287
|
+
Uuid::new_v4()
|
|
288
|
+
);
|
|
289
|
+
acquire_thread_lock(state, &message, &lock_owner).await?;
|
|
290
|
+
let result = answer_claimed_message(state, &message, &lock_owner).await;
|
|
291
|
+
if let Err(error) = &result {
|
|
292
|
+
mark_message_failed(state, &message, error).await?;
|
|
293
|
+
publish_message_status(
|
|
294
|
+
state,
|
|
295
|
+
&message,
|
|
296
|
+
"FAILED",
|
|
297
|
+
Some(&error.to_string()),
|
|
298
|
+
)
|
|
299
|
+
.await?;
|
|
300
|
+
release_thread_lock(state, &message, &lock_owner, None).await?;
|
|
301
|
+
}
|
|
302
|
+
result?;
|
|
303
|
+
Ok(RecordOutcome::Processed)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async fn answer_claimed_message(
|
|
307
|
+
state: &AppState,
|
|
308
|
+
message: &ChatMessage,
|
|
309
|
+
lock_owner: &str,
|
|
310
|
+
) -> Result<()> {
|
|
311
|
+
let selected_model = resolve_message_model(&state.config.model, message);
|
|
312
|
+
let mut context = load_prompt_context(state, message).await?;
|
|
313
|
+
let static_prompt = load_static_prompt_context(state).await?;
|
|
314
|
+
let starting_sequence = context.last_sequence_number.max(message.sequence_number);
|
|
315
|
+
let now = now_iso();
|
|
316
|
+
let assistant_record = PersistedMessage {
|
|
317
|
+
id: format!("message-console-assistant-{}", Uuid::new_v4()),
|
|
318
|
+
thread_id: message.thread_id.clone(),
|
|
319
|
+
parent_message_id: Some(message.id.clone()),
|
|
320
|
+
created_at: now.clone(),
|
|
321
|
+
sequence_number: starting_sequence + 1,
|
|
322
|
+
role: "ASSISTANT".to_string(),
|
|
323
|
+
message_kind: MESSAGE_KIND_CHAT_TURN.to_string(),
|
|
324
|
+
message_type: "MESSAGE".to_string(),
|
|
325
|
+
content: String::new(),
|
|
326
|
+
summary: "Thinking...".to_string(),
|
|
327
|
+
response_status: Some("RUNNING".to_string()),
|
|
328
|
+
response_error: None,
|
|
329
|
+
response_started_at: Some(now.clone()),
|
|
330
|
+
response_completed_at: None,
|
|
331
|
+
metadata: json!({
|
|
332
|
+
"responder": "rust-lambda",
|
|
333
|
+
"model": selected_model,
|
|
334
|
+
"triggerMessageId": message.id,
|
|
335
|
+
"triggerCreatedAt": message.created_at,
|
|
336
|
+
"streaming": true,
|
|
337
|
+
}),
|
|
338
|
+
};
|
|
339
|
+
create_message_graphql(state, &assistant_record, &now).await?;
|
|
340
|
+
|
|
341
|
+
let mut writer = AssistantStreamWriter::new(state, assistant_record.clone());
|
|
342
|
+
let mut next_sequence = assistant_record.sequence_number + 1;
|
|
343
|
+
let tool_and_assistant =
|
|
344
|
+
run_agent_turn(
|
|
345
|
+
state,
|
|
346
|
+
message,
|
|
347
|
+
&context,
|
|
348
|
+
&static_prompt,
|
|
349
|
+
&selected_model,
|
|
350
|
+
&mut writer,
|
|
351
|
+
&mut next_sequence,
|
|
352
|
+
)
|
|
353
|
+
.await?;
|
|
354
|
+
if writer.content.trim().is_empty() && !tool_and_assistant.assistant_content.trim().is_empty() {
|
|
355
|
+
writer
|
|
356
|
+
.push_delta(&tool_and_assistant.assistant_content)
|
|
357
|
+
.await?;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let assistant_content = tool_and_assistant.assistant_content;
|
|
361
|
+
let mut completed_metadata = assistant_record.metadata.clone();
|
|
362
|
+
if !tool_and_assistant.model_context.is_null() {
|
|
363
|
+
if let Some(entry) = completed_metadata.as_object_mut() {
|
|
364
|
+
entry.insert("modelContext".to_string(), tool_and_assistant.model_context.clone());
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
let final_assistant_sequence = if next_sequence == assistant_record.sequence_number + 1 {
|
|
368
|
+
assistant_record.sequence_number
|
|
369
|
+
} else {
|
|
370
|
+
next_sequence
|
|
371
|
+
};
|
|
372
|
+
let completed_assistant_record = PersistedMessage {
|
|
373
|
+
sequence_number: final_assistant_sequence,
|
|
374
|
+
content: assistant_content.clone(),
|
|
375
|
+
summary: truncate_summary(&assistant_content),
|
|
376
|
+
response_status: Some("COMPLETED".to_string()),
|
|
377
|
+
response_started_at: assistant_record.response_started_at.clone(),
|
|
378
|
+
response_completed_at: Some(now_iso()),
|
|
379
|
+
metadata: completed_metadata,
|
|
380
|
+
..assistant_record
|
|
381
|
+
};
|
|
382
|
+
let mut completion_update = json!({
|
|
383
|
+
"id": completed_assistant_record.id,
|
|
384
|
+
"content": completed_assistant_record.content,
|
|
385
|
+
"summary": completed_assistant_record.summary,
|
|
386
|
+
"responseTarget": message.response_target,
|
|
387
|
+
"responseStatus": "COMPLETED",
|
|
388
|
+
"createdAt": completed_assistant_record.created_at,
|
|
389
|
+
"responseStartedAt": completed_assistant_record.response_started_at,
|
|
390
|
+
"responseCompletedAt": completed_assistant_record.response_completed_at,
|
|
391
|
+
"responseError": Value::Null,
|
|
392
|
+
"metadata": completed_assistant_record.metadata.to_string(),
|
|
393
|
+
"updatedAt": now_iso(),
|
|
394
|
+
});
|
|
395
|
+
if completed_assistant_record.sequence_number != starting_sequence + 1 {
|
|
396
|
+
completion_update["sequenceNumber"] = Value::from(completed_assistant_record.sequence_number);
|
|
397
|
+
}
|
|
398
|
+
update_message_graphql(state, completion_update).await?;
|
|
399
|
+
context
|
|
400
|
+
.recent_messages
|
|
401
|
+
.push(CachedPromptMessage::from_persisted(
|
|
402
|
+
&completed_assistant_record,
|
|
403
|
+
));
|
|
404
|
+
let persisted_count = usize::try_from(
|
|
405
|
+
(next_sequence - (assistant_record.sequence_number + 1)).max(0),
|
|
406
|
+
)
|
|
407
|
+
.unwrap_or(0)
|
|
408
|
+
+ 1;
|
|
409
|
+
|
|
410
|
+
context.last_sequence_number = completed_assistant_record.sequence_number;
|
|
411
|
+
context.last_message_id = completed_assistant_record.id.clone();
|
|
412
|
+
trim_recent_messages(&mut context.recent_messages);
|
|
413
|
+
context.context_digest = compute_context_digest(&context);
|
|
414
|
+
context.updated_at = now.clone();
|
|
415
|
+
write_context_cache(&state.config.cache_root, &context)?;
|
|
416
|
+
|
|
417
|
+
mark_message_completed(state, message).await?;
|
|
418
|
+
publish_message_status(state, message, "COMPLETED", None)
|
|
419
|
+
.await?;
|
|
420
|
+
release_thread_lock(state, message, lock_owner, Some(&context)).await?;
|
|
421
|
+
update_thread_graphql(state, &message.thread_id, &context).await?;
|
|
422
|
+
info!(
|
|
423
|
+
message_id = message.id,
|
|
424
|
+
thread_id = message.thread_id,
|
|
425
|
+
assistant_message_id = completed_assistant_record.id,
|
|
426
|
+
persisted = persisted_count,
|
|
427
|
+
cache_digest = context.context_digest,
|
|
428
|
+
"console chat message completed"
|
|
429
|
+
);
|
|
430
|
+
Ok(())
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async fn answer_local_message(state: &AppState, message: &ChatMessage) -> Result<()> {
|
|
434
|
+
let selected_model = resolve_message_model(&state.config.model, message);
|
|
435
|
+
let mut context = one_message_context(message);
|
|
436
|
+
let static_prompt = load_static_prompt_context(state).await?;
|
|
437
|
+
let starting_sequence = context.last_sequence_number.max(message.sequence_number);
|
|
438
|
+
let now = now_iso();
|
|
439
|
+
let assistant_record = PersistedMessage {
|
|
440
|
+
id: format!("message-console-assistant-{}", Uuid::new_v4()),
|
|
441
|
+
thread_id: message.thread_id.clone(),
|
|
442
|
+
parent_message_id: Some(message.id.clone()),
|
|
443
|
+
created_at: now.clone(),
|
|
444
|
+
sequence_number: starting_sequence + 1,
|
|
445
|
+
role: "ASSISTANT".to_string(),
|
|
446
|
+
message_kind: MESSAGE_KIND_CHAT_TURN.to_string(),
|
|
447
|
+
message_type: "MESSAGE".to_string(),
|
|
448
|
+
content: String::new(),
|
|
449
|
+
summary: "Thinking...".to_string(),
|
|
450
|
+
response_status: Some("RUNNING".to_string()),
|
|
451
|
+
response_error: None,
|
|
452
|
+
response_started_at: Some(now.clone()),
|
|
453
|
+
response_completed_at: None,
|
|
454
|
+
metadata: json!({
|
|
455
|
+
"responder": "rust-local",
|
|
456
|
+
"model": selected_model,
|
|
457
|
+
"triggerMessageId": message.id,
|
|
458
|
+
"triggerCreatedAt": message.created_at,
|
|
459
|
+
"streaming": true,
|
|
460
|
+
}),
|
|
461
|
+
};
|
|
462
|
+
create_message_graphql(state, &assistant_record, &now).await?;
|
|
463
|
+
|
|
464
|
+
let mut writer = AssistantStreamWriter::new(state, assistant_record.clone());
|
|
465
|
+
let mut next_sequence = assistant_record.sequence_number + 1;
|
|
466
|
+
let tool_and_assistant =
|
|
467
|
+
run_agent_turn(
|
|
468
|
+
state,
|
|
469
|
+
message,
|
|
470
|
+
&context,
|
|
471
|
+
&static_prompt,
|
|
472
|
+
&selected_model,
|
|
473
|
+
&mut writer,
|
|
474
|
+
&mut next_sequence,
|
|
475
|
+
)
|
|
476
|
+
.await?;
|
|
477
|
+
if writer.content.trim().is_empty() && !tool_and_assistant.assistant_content.trim().is_empty() {
|
|
478
|
+
writer.push_delta(&tool_and_assistant.assistant_content).await?;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
let assistant_content = tool_and_assistant.assistant_content;
|
|
482
|
+
let mut completed_metadata = assistant_record.metadata.clone();
|
|
483
|
+
if !tool_and_assistant.model_context.is_null() {
|
|
484
|
+
if let Some(entry) = completed_metadata.as_object_mut() {
|
|
485
|
+
entry.insert("modelContext".to_string(), tool_and_assistant.model_context.clone());
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
let final_assistant_sequence = if next_sequence == assistant_record.sequence_number + 1 {
|
|
489
|
+
assistant_record.sequence_number
|
|
490
|
+
} else {
|
|
491
|
+
next_sequence
|
|
492
|
+
};
|
|
493
|
+
let completed_assistant_record = PersistedMessage {
|
|
494
|
+
sequence_number: final_assistant_sequence,
|
|
495
|
+
content: assistant_content.clone(),
|
|
496
|
+
summary: truncate_summary(&assistant_content),
|
|
497
|
+
response_status: Some("COMPLETED".to_string()),
|
|
498
|
+
response_started_at: assistant_record.response_started_at.clone(),
|
|
499
|
+
response_completed_at: Some(now_iso()),
|
|
500
|
+
metadata: completed_metadata,
|
|
501
|
+
..assistant_record
|
|
502
|
+
};
|
|
503
|
+
let mut completion_update = json!({
|
|
504
|
+
"id": completed_assistant_record.id,
|
|
505
|
+
"content": completed_assistant_record.content,
|
|
506
|
+
"summary": completed_assistant_record.summary,
|
|
507
|
+
"responseTarget": message.response_target,
|
|
508
|
+
"responseStatus": "COMPLETED",
|
|
509
|
+
"createdAt": completed_assistant_record.created_at,
|
|
510
|
+
"responseStartedAt": completed_assistant_record.response_started_at,
|
|
511
|
+
"responseCompletedAt": completed_assistant_record.response_completed_at,
|
|
512
|
+
"responseError": Value::Null,
|
|
513
|
+
"metadata": completed_assistant_record.metadata.to_string(),
|
|
514
|
+
"updatedAt": now_iso(),
|
|
515
|
+
});
|
|
516
|
+
if completed_assistant_record.sequence_number != starting_sequence + 1 {
|
|
517
|
+
completion_update["sequenceNumber"] = Value::from(completed_assistant_record.sequence_number);
|
|
518
|
+
}
|
|
519
|
+
update_message_graphql(state, completion_update).await?;
|
|
520
|
+
context
|
|
521
|
+
.recent_messages
|
|
522
|
+
.push(CachedPromptMessage::from_persisted(
|
|
523
|
+
&completed_assistant_record,
|
|
524
|
+
));
|
|
525
|
+
context.last_sequence_number = completed_assistant_record.sequence_number;
|
|
526
|
+
context.last_message_id = completed_assistant_record.id.clone();
|
|
527
|
+
trim_recent_messages(&mut context.recent_messages);
|
|
528
|
+
context.context_digest = compute_context_digest(&context);
|
|
529
|
+
context.updated_at = now.clone();
|
|
530
|
+
write_context_cache(&state.config.cache_root, &context)?;
|
|
531
|
+
update_thread_graphql(state, &message.thread_id, &context).await?;
|
|
532
|
+
info!(
|
|
533
|
+
message_id = message.id,
|
|
534
|
+
thread_id = message.thread_id,
|
|
535
|
+
assistant_message_id = completed_assistant_record.id,
|
|
536
|
+
"local console chat message completed"
|
|
537
|
+
);
|
|
538
|
+
Ok(())
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
#[derive(Debug, Clone)]
|
|
542
|
+
struct ChatMessage {
|
|
543
|
+
id: String,
|
|
544
|
+
thread_id: String,
|
|
545
|
+
role: String,
|
|
546
|
+
message_kind: String,
|
|
547
|
+
message_type: String,
|
|
548
|
+
content: String,
|
|
549
|
+
response_target: String,
|
|
550
|
+
response_status: String,
|
|
551
|
+
sequence_number: i64,
|
|
552
|
+
created_at: String,
|
|
553
|
+
metadata: Value,
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
impl ChatMessage {
|
|
557
|
+
fn from_stream_image(image: &serde_json::Map<String, Value>) -> Result<Option<Self>> {
|
|
558
|
+
let metadata = image
|
|
559
|
+
.get("metadata")
|
|
560
|
+
.map(dynamodb_json_to_value)
|
|
561
|
+
.unwrap_or(Value::Null);
|
|
562
|
+
let id = stream_string(image, "id")?;
|
|
563
|
+
let message_kind = stream_string(image, "messageKind")?;
|
|
564
|
+
let role = stream_string(image, "role").unwrap_or_default();
|
|
565
|
+
let message_type =
|
|
566
|
+
stream_string(image, "messageType").unwrap_or_else(|_| "MESSAGE".to_string());
|
|
567
|
+
let response_status = stream_string(image, "responseStatus").unwrap_or_default();
|
|
568
|
+
if message_kind != MESSAGE_KIND_CHAT_TURN
|
|
569
|
+
|| role != "USER"
|
|
570
|
+
|| message_type != "MESSAGE"
|
|
571
|
+
|| response_status != "PENDING"
|
|
572
|
+
{
|
|
573
|
+
return Ok(None);
|
|
574
|
+
}
|
|
575
|
+
let thread_id = match stream_string(image, "threadId") {
|
|
576
|
+
Ok(value) => value,
|
|
577
|
+
Err(error) => {
|
|
578
|
+
warn!(
|
|
579
|
+
message_id = id,
|
|
580
|
+
error = %error,
|
|
581
|
+
"skipping pending console chat message without threadId"
|
|
582
|
+
);
|
|
583
|
+
return Ok(None);
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
Ok(Some(Self {
|
|
587
|
+
id,
|
|
588
|
+
thread_id,
|
|
589
|
+
role,
|
|
590
|
+
message_kind,
|
|
591
|
+
message_type,
|
|
592
|
+
content: stream_string(image, "content")
|
|
593
|
+
.or_else(|_| stream_string(image, "summary"))?,
|
|
594
|
+
response_target: stream_string(image, "responseTarget").unwrap_or_default(),
|
|
595
|
+
response_status,
|
|
596
|
+
sequence_number: stream_i64(image, "sequenceNumber").unwrap_or(0),
|
|
597
|
+
created_at: stream_string(image, "createdAt").unwrap_or_else(|_| now_iso()),
|
|
598
|
+
metadata,
|
|
599
|
+
}))
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
fn should_handle(&self, expected_target: &str) -> bool {
|
|
603
|
+
self.message_kind == MESSAGE_KIND_CHAT_TURN
|
|
604
|
+
&& self.role == "USER"
|
|
605
|
+
&& self.message_type == "MESSAGE"
|
|
606
|
+
&& self.response_status == "PENDING"
|
|
607
|
+
&& self.response_target == expected_target
|
|
608
|
+
&& !self.thread_id.is_empty()
|
|
609
|
+
&& !self.content.trim().is_empty()
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async fn claim_message(state: &AppState, message: &ChatMessage) -> Result<bool> {
|
|
614
|
+
let response = state
|
|
615
|
+
.dynamo
|
|
616
|
+
.update_item()
|
|
617
|
+
.table_name(&state.config.message_table)
|
|
618
|
+
.key("id", av_s(&message.id))
|
|
619
|
+
.update_expression("SET responseStatus = :running, responseOwner = :owner, responseStartedAt = :now, updatedAt = :now")
|
|
620
|
+
.condition_expression("responseStatus = :pending")
|
|
621
|
+
.expression_attribute_values(":running", av_s("RUNNING"))
|
|
622
|
+
.expression_attribute_values(":pending", av_s("PENDING"))
|
|
623
|
+
.expression_attribute_values(":owner", av_s("rust-lambda"))
|
|
624
|
+
.expression_attribute_values(":now", av_s(&now_iso()))
|
|
625
|
+
.send()
|
|
626
|
+
.await;
|
|
627
|
+
match response {
|
|
628
|
+
Ok(_) => Ok(true),
|
|
629
|
+
Err(error) => {
|
|
630
|
+
let text = error.to_string();
|
|
631
|
+
if text.contains("ConditionalCheckFailed")
|
|
632
|
+
|| text.contains("conditional request failed")
|
|
633
|
+
{
|
|
634
|
+
warn!(message_id = message.id, "message was already claimed");
|
|
635
|
+
Ok(false)
|
|
636
|
+
} else {
|
|
637
|
+
Err(error).context("claim console chat message")
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async fn acquire_thread_lock(state: &AppState, message: &ChatMessage, owner: &str) -> Result<()> {
|
|
644
|
+
let now = now_iso();
|
|
645
|
+
let expires = (Utc::now() + Duration::seconds(300)).to_rfc3339();
|
|
646
|
+
state
|
|
647
|
+
.dynamo
|
|
648
|
+
.update_item()
|
|
649
|
+
.table_name(&state.config.thread_table)
|
|
650
|
+
.key("id", av_s(&message.thread_id))
|
|
651
|
+
.update_expression("SET activeResponseMessageId = :messageId, responseLockOwner = :owner, responseLockExpiresAt = :expires, updatedAt = :now")
|
|
652
|
+
.condition_expression("attribute_not_exists(responseLockExpiresAt) OR responseLockExpiresAt < :now OR activeResponseMessageId = :messageId")
|
|
653
|
+
.expression_attribute_values(":messageId", av_s(&message.id))
|
|
654
|
+
.expression_attribute_values(":owner", av_s(owner))
|
|
655
|
+
.expression_attribute_values(":expires", av_s(&expires))
|
|
656
|
+
.expression_attribute_values(":now", av_s(&now))
|
|
657
|
+
.send()
|
|
658
|
+
.await
|
|
659
|
+
.context("acquire MessageThread response lock")?;
|
|
660
|
+
Ok(())
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async fn release_thread_lock(
|
|
664
|
+
state: &AppState,
|
|
665
|
+
message: &ChatMessage,
|
|
666
|
+
owner: &str,
|
|
667
|
+
context: Option<&ThreadContextCache>,
|
|
668
|
+
) -> Result<()> {
|
|
669
|
+
let now = now_iso();
|
|
670
|
+
let mut update = state
|
|
671
|
+
.dynamo
|
|
672
|
+
.update_item()
|
|
673
|
+
.table_name(&state.config.thread_table)
|
|
674
|
+
.key("id", av_s(&message.thread_id))
|
|
675
|
+
.condition_expression("responseLockOwner = :owner")
|
|
676
|
+
.expression_attribute_values(":owner", av_s(owner))
|
|
677
|
+
.expression_attribute_values(":now", av_s(&now));
|
|
678
|
+
|
|
679
|
+
if let Some(context) = context {
|
|
680
|
+
update = update
|
|
681
|
+
.update_expression("SET lastMessageId = :lastMessageId, lastMessageAt = :lastMessageAt, contextDigest = :contextDigest, messageCount = :messageCount, updatedAt = :now REMOVE activeResponseMessageId, responseLockOwner, responseLockExpiresAt")
|
|
682
|
+
.expression_attribute_values(":lastMessageId", av_s(&context.last_message_id))
|
|
683
|
+
.expression_attribute_values(":lastMessageAt", av_s(&context.updated_at))
|
|
684
|
+
.expression_attribute_values(":contextDigest", av_s(&context.context_digest))
|
|
685
|
+
.expression_attribute_values(":messageCount", AttributeValue::N(context.last_sequence_number.to_string()));
|
|
686
|
+
} else {
|
|
687
|
+
update = update.update_expression("SET updatedAt = :now REMOVE activeResponseMessageId, responseLockOwner, responseLockExpiresAt");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
match update.send().await {
|
|
691
|
+
Ok(_) => Ok(()),
|
|
692
|
+
Err(error) => {
|
|
693
|
+
warn!(thread_id = message.thread_id, error = %error, "failed to release thread lock");
|
|
694
|
+
Ok(())
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async fn mark_message_completed(state: &AppState, message: &ChatMessage) -> Result<()> {
|
|
700
|
+
let now = now_iso();
|
|
701
|
+
state
|
|
702
|
+
.dynamo
|
|
703
|
+
.update_item()
|
|
704
|
+
.table_name(&state.config.message_table)
|
|
705
|
+
.key("id", av_s(&message.id))
|
|
706
|
+
.update_expression(
|
|
707
|
+
"SET responseStatus = :completed, responseCompletedAt = :now, updatedAt = :now",
|
|
708
|
+
)
|
|
709
|
+
.expression_attribute_values(":completed", av_s("COMPLETED"))
|
|
710
|
+
.expression_attribute_values(":now", av_s(&now))
|
|
711
|
+
.send()
|
|
712
|
+
.await
|
|
713
|
+
.context("mark console chat message completed")?;
|
|
714
|
+
Ok(())
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async fn mark_message_failed(
|
|
718
|
+
state: &AppState,
|
|
719
|
+
message: &ChatMessage,
|
|
720
|
+
error: &anyhow::Error,
|
|
721
|
+
) -> Result<()> {
|
|
722
|
+
let now = now_iso();
|
|
723
|
+
state
|
|
724
|
+
.dynamo
|
|
725
|
+
.update_item()
|
|
726
|
+
.table_name(&state.config.message_table)
|
|
727
|
+
.key("id", av_s(&message.id))
|
|
728
|
+
.update_expression("SET responseStatus = :failed, responseCompletedAt = :now, responseError = :error, updatedAt = :now")
|
|
729
|
+
.expression_attribute_values(":failed", av_s("FAILED"))
|
|
730
|
+
.expression_attribute_values(":now", av_s(&now))
|
|
731
|
+
.expression_attribute_values(":error", av_s(&error.to_string()))
|
|
732
|
+
.send()
|
|
733
|
+
.await
|
|
734
|
+
.context("mark console chat message failed")?;
|
|
735
|
+
Ok(())
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
739
|
+
struct ThreadContextCache {
|
|
740
|
+
schema_version: u32,
|
|
741
|
+
thread_id: String,
|
|
742
|
+
last_sequence_number: i64,
|
|
743
|
+
last_message_id: String,
|
|
744
|
+
context_digest: String,
|
|
745
|
+
rolling_summary: String,
|
|
746
|
+
recent_messages: Vec<CachedPromptMessage>,
|
|
747
|
+
updated_at: String,
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
751
|
+
struct CachedPromptMessage {
|
|
752
|
+
id: String,
|
|
753
|
+
sequence_number: i64,
|
|
754
|
+
role: String,
|
|
755
|
+
#[serde(default)]
|
|
756
|
+
message_kind: String,
|
|
757
|
+
#[serde(default)]
|
|
758
|
+
message_type: String,
|
|
759
|
+
content: String,
|
|
760
|
+
#[serde(default)]
|
|
761
|
+
metadata: Value,
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
impl CachedPromptMessage {
|
|
765
|
+
fn from_chat(message: &ChatMessage) -> Self {
|
|
766
|
+
Self {
|
|
767
|
+
id: message.id.clone(),
|
|
768
|
+
sequence_number: message.sequence_number,
|
|
769
|
+
role: message.role.clone(),
|
|
770
|
+
message_kind: message.message_kind.clone(),
|
|
771
|
+
message_type: message.message_type.clone(),
|
|
772
|
+
content: message.content.clone(),
|
|
773
|
+
metadata: message.metadata.clone(),
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
fn from_persisted(message: &PersistedMessage) -> Self {
|
|
778
|
+
Self {
|
|
779
|
+
id: message.id.clone(),
|
|
780
|
+
sequence_number: message.sequence_number,
|
|
781
|
+
role: message.role.clone(),
|
|
782
|
+
message_kind: message.message_kind.clone(),
|
|
783
|
+
message_type: message.message_type.clone(),
|
|
784
|
+
content: message.content.clone(),
|
|
785
|
+
metadata: message.metadata.clone(),
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async fn load_prompt_context(
|
|
791
|
+
state: &AppState,
|
|
792
|
+
message: &ChatMessage,
|
|
793
|
+
) -> Result<ThreadContextCache> {
|
|
794
|
+
if let Some(mut cache) = read_context_cache(&state.config.cache_root, &message.thread_id)? {
|
|
795
|
+
if cache_is_valid_for_message(&cache, message) {
|
|
796
|
+
if !cache
|
|
797
|
+
.recent_messages
|
|
798
|
+
.iter()
|
|
799
|
+
.any(|entry| entry.id == message.id)
|
|
800
|
+
{
|
|
801
|
+
cache
|
|
802
|
+
.recent_messages
|
|
803
|
+
.push(CachedPromptMessage::from_chat(message));
|
|
804
|
+
cache.last_sequence_number = message.sequence_number;
|
|
805
|
+
cache.last_message_id = message.id.clone();
|
|
806
|
+
trim_recent_messages(&mut cache.recent_messages);
|
|
807
|
+
}
|
|
808
|
+
info!(
|
|
809
|
+
thread_id = message.thread_id,
|
|
810
|
+
"loaded prompt context from /tmp cache"
|
|
811
|
+
);
|
|
812
|
+
return Ok(cache);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
let mut cache = match rebuild_context_from_dynamodb(state, message).await {
|
|
816
|
+
Ok(cache) => cache,
|
|
817
|
+
Err(error) => {
|
|
818
|
+
warn!(
|
|
819
|
+
thread_id = message.thread_id,
|
|
820
|
+
error = %format_error_chain(&error),
|
|
821
|
+
"failed to rebuild prompt context from DynamoDB; falling back to trigger message only"
|
|
822
|
+
);
|
|
823
|
+
one_message_context(message)
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
if !cache
|
|
827
|
+
.recent_messages
|
|
828
|
+
.iter()
|
|
829
|
+
.any(|entry| entry.id == message.id)
|
|
830
|
+
{
|
|
831
|
+
cache
|
|
832
|
+
.recent_messages
|
|
833
|
+
.push(CachedPromptMessage::from_chat(message));
|
|
834
|
+
}
|
|
835
|
+
cache.last_sequence_number = cache
|
|
836
|
+
.recent_messages
|
|
837
|
+
.iter()
|
|
838
|
+
.map(|entry| entry.sequence_number)
|
|
839
|
+
.max()
|
|
840
|
+
.unwrap_or(message.sequence_number);
|
|
841
|
+
cache.last_message_id = cache
|
|
842
|
+
.recent_messages
|
|
843
|
+
.iter()
|
|
844
|
+
.max_by_key(|entry| entry.sequence_number)
|
|
845
|
+
.map(|entry| entry.id.clone())
|
|
846
|
+
.unwrap_or_else(|| message.id.clone());
|
|
847
|
+
trim_recent_messages(&mut cache.recent_messages);
|
|
848
|
+
cache.context_digest = compute_context_digest(&cache);
|
|
849
|
+
write_context_cache(&state.config.cache_root, &cache)?;
|
|
850
|
+
info!(
|
|
851
|
+
thread_id = message.thread_id,
|
|
852
|
+
"rebuilt prompt context from DynamoDB"
|
|
853
|
+
);
|
|
854
|
+
Ok(cache)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
fn one_message_context(message: &ChatMessage) -> ThreadContextCache {
|
|
858
|
+
let mut cache = ThreadContextCache {
|
|
859
|
+
schema_version: CONTEXT_CACHE_SCHEMA_VERSION,
|
|
860
|
+
thread_id: message.thread_id.clone(),
|
|
861
|
+
last_sequence_number: message.sequence_number,
|
|
862
|
+
last_message_id: message.id.clone(),
|
|
863
|
+
context_digest: String::new(),
|
|
864
|
+
rolling_summary: String::new(),
|
|
865
|
+
recent_messages: vec![CachedPromptMessage::from_chat(message)],
|
|
866
|
+
updated_at: now_iso(),
|
|
867
|
+
};
|
|
868
|
+
cache.context_digest = compute_context_digest(&cache);
|
|
869
|
+
cache
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
fn cache_is_valid_for_message(cache: &ThreadContextCache, message: &ChatMessage) -> bool {
|
|
873
|
+
if cache.schema_version != CONTEXT_CACHE_SCHEMA_VERSION || cache.thread_id != message.thread_id
|
|
874
|
+
{
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
let previous_sequence =
|
|
878
|
+
metadata_i64_field(&message.metadata, "previousSequenceNumber").unwrap_or(message.sequence_number - 1);
|
|
879
|
+
let previous_digest = metadata_string_field(&message.metadata, "previousContextDigest");
|
|
880
|
+
cache.last_sequence_number == previous_sequence
|
|
881
|
+
&& previous_digest
|
|
882
|
+
.as_deref()
|
|
883
|
+
.map(|digest| digest == cache.context_digest)
|
|
884
|
+
.unwrap_or(true)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async fn rebuild_context_from_dynamodb(
|
|
888
|
+
state: &AppState,
|
|
889
|
+
message: &ChatMessage,
|
|
890
|
+
) -> Result<ThreadContextCache> {
|
|
891
|
+
let result = state
|
|
892
|
+
.dynamo
|
|
893
|
+
.query()
|
|
894
|
+
.table_name(&state.config.message_table)
|
|
895
|
+
.index_name(&state.config.thread_sequence_index)
|
|
896
|
+
.key_condition_expression("threadId = :threadId")
|
|
897
|
+
.expression_attribute_values(":threadId", av_s(&message.thread_id))
|
|
898
|
+
.scan_index_forward(false)
|
|
899
|
+
.limit(32)
|
|
900
|
+
.send()
|
|
901
|
+
.await
|
|
902
|
+
.context("query Message tail by threadId/sequenceNumber")?;
|
|
903
|
+
let mut messages: Vec<CachedPromptMessage> = result
|
|
904
|
+
.items()
|
|
905
|
+
.iter()
|
|
906
|
+
.filter_map(cached_message_from_item)
|
|
907
|
+
.collect();
|
|
908
|
+
messages.sort_by_key(|entry| entry.sequence_number);
|
|
909
|
+
trim_recent_messages(&mut messages);
|
|
910
|
+
let last = messages
|
|
911
|
+
.iter()
|
|
912
|
+
.max_by_key(|entry| entry.sequence_number)
|
|
913
|
+
.cloned()
|
|
914
|
+
.unwrap_or_else(|| CachedPromptMessage::from_chat(message));
|
|
915
|
+
let mut cache = ThreadContextCache {
|
|
916
|
+
schema_version: CONTEXT_CACHE_SCHEMA_VERSION,
|
|
917
|
+
thread_id: message.thread_id.clone(),
|
|
918
|
+
last_sequence_number: last.sequence_number,
|
|
919
|
+
last_message_id: last.id,
|
|
920
|
+
context_digest: String::new(),
|
|
921
|
+
rolling_summary: String::new(),
|
|
922
|
+
recent_messages: messages,
|
|
923
|
+
updated_at: now_iso(),
|
|
924
|
+
};
|
|
925
|
+
cache.context_digest = compute_context_digest(&cache);
|
|
926
|
+
Ok(cache)
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
fn cached_message_from_item(item: &HashMap<String, AttributeValue>) -> Option<CachedPromptMessage> {
|
|
930
|
+
let role = attr_string(item.get("role"))?;
|
|
931
|
+
let message_kind = attr_string(item.get("messageKind")).unwrap_or_default();
|
|
932
|
+
let allows_system = role == "SYSTEM" && message_kind == MESSAGE_KIND_WEB_CONTEXT;
|
|
933
|
+
if role != "USER" && role != "ASSISTANT" && role != "TOOL" && !allows_system {
|
|
934
|
+
return None;
|
|
935
|
+
}
|
|
936
|
+
let message_type =
|
|
937
|
+
attr_string(item.get("messageType")).unwrap_or_else(|| "MESSAGE".to_string());
|
|
938
|
+
if message_type != "MESSAGE" && message_type != "TOOL_CALL" && message_type != "TOOL_RESPONSE" {
|
|
939
|
+
return None;
|
|
940
|
+
}
|
|
941
|
+
Some(CachedPromptMessage {
|
|
942
|
+
id: attr_string(item.get("id"))?,
|
|
943
|
+
sequence_number: attr_i64(item.get("sequenceNumber")).unwrap_or(0),
|
|
944
|
+
role,
|
|
945
|
+
message_kind,
|
|
946
|
+
message_type,
|
|
947
|
+
content: attr_string(item.get("content")).or_else(|| attr_string(item.get("summary")))?,
|
|
948
|
+
metadata: attr_json(item.get("metadata")),
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
fn read_context_cache(root: &Path, thread_id: &str) -> Result<Option<ThreadContextCache>> {
|
|
953
|
+
let path = context_cache_path(root, thread_id);
|
|
954
|
+
if !path.exists() {
|
|
955
|
+
return Ok(None);
|
|
956
|
+
}
|
|
957
|
+
let text = fs::read_to_string(&path)
|
|
958
|
+
.with_context(|| format!("read context cache {}", path.display()))?;
|
|
959
|
+
let cache = serde_json::from_str(&text)
|
|
960
|
+
.with_context(|| format!("parse context cache {}", path.display()))?;
|
|
961
|
+
Ok(Some(cache))
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
fn write_context_cache(root: &Path, cache: &ThreadContextCache) -> Result<()> {
|
|
965
|
+
fs::create_dir_all(root)
|
|
966
|
+
.with_context(|| format!("create context cache directory {}", root.display()))?;
|
|
967
|
+
let path = context_cache_path(root, &cache.thread_id);
|
|
968
|
+
let tmp_path = path.with_extension("json.tmp");
|
|
969
|
+
let text = serde_json::to_string(cache)?;
|
|
970
|
+
fs::write(&tmp_path, text)
|
|
971
|
+
.with_context(|| format!("write context cache {}", tmp_path.display()))?;
|
|
972
|
+
fs::rename(&tmp_path, &path)
|
|
973
|
+
.with_context(|| format!("move context cache {}", path.display()))?;
|
|
974
|
+
Ok(())
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
fn context_cache_path(root: &Path, thread_id: &str) -> PathBuf {
|
|
978
|
+
let safe = thread_id
|
|
979
|
+
.chars()
|
|
980
|
+
.map(|ch| {
|
|
981
|
+
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
|
982
|
+
ch
|
|
983
|
+
} else {
|
|
984
|
+
'_'
|
|
985
|
+
}
|
|
986
|
+
})
|
|
987
|
+
.collect::<String>();
|
|
988
|
+
root.join(format!("{safe}.json"))
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
fn trim_recent_messages(messages: &mut Vec<CachedPromptMessage>) {
|
|
992
|
+
messages.sort_by_key(|entry| entry.sequence_number);
|
|
993
|
+
messages.dedup_by(|left, right| left.id == right.id);
|
|
994
|
+
if messages.len() > 24 {
|
|
995
|
+
let drop_count = messages.len() - 24;
|
|
996
|
+
messages.drain(0..drop_count);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
fn compute_context_digest(cache: &ThreadContextCache) -> String {
|
|
1001
|
+
let mut hasher = Sha256::new();
|
|
1002
|
+
hasher.update(cache.thread_id.as_bytes());
|
|
1003
|
+
hasher.update(cache.last_sequence_number.to_string().as_bytes());
|
|
1004
|
+
for message in &cache.recent_messages {
|
|
1005
|
+
hasher.update(message.id.as_bytes());
|
|
1006
|
+
hasher.update(message.role.as_bytes());
|
|
1007
|
+
hasher.update(message.content.as_bytes());
|
|
1008
|
+
}
|
|
1009
|
+
to_hex(&hasher.finalize())
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async fn load_static_prompt_context(state: &AppState) -> Result<StaticPromptContext> {
|
|
1013
|
+
if let Some(cache) = read_static_prompt_cache(&state.config.cache_root)? {
|
|
1014
|
+
let now_epoch = Utc::now().timestamp();
|
|
1015
|
+
if cache.schema_version == STATIC_PROMPT_CACHE_SCHEMA_VERSION
|
|
1016
|
+
&& cache.graphql_endpoint == state.config.graphql_endpoint
|
|
1017
|
+
&& cache.expires_at_epoch > now_epoch
|
|
1018
|
+
{
|
|
1019
|
+
return Ok(cache);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
let (mission, policy) = load_publication_doctrine(state).await?;
|
|
1024
|
+
let docs_index = match load_execute_tactus_docs_index(state).await {
|
|
1025
|
+
Ok(entries) => entries,
|
|
1026
|
+
Err(error) => {
|
|
1027
|
+
warn!(
|
|
1028
|
+
error = %format_error_chain(&error),
|
|
1029
|
+
"failed to load execute_tactus docs index; using empty docs list"
|
|
1030
|
+
);
|
|
1031
|
+
Vec::new()
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
let generated_at = now_iso();
|
|
1035
|
+
let expires_at_epoch = Utc::now()
|
|
1036
|
+
.checked_add_signed(Duration::seconds(
|
|
1037
|
+
state.config.static_prompt_cache_ttl_seconds,
|
|
1038
|
+
))
|
|
1039
|
+
.map(|dt| dt.timestamp())
|
|
1040
|
+
.unwrap_or_else(|| Utc::now().timestamp() + state.config.static_prompt_cache_ttl_seconds);
|
|
1041
|
+
let cache = StaticPromptContext {
|
|
1042
|
+
schema_version: STATIC_PROMPT_CACHE_SCHEMA_VERSION,
|
|
1043
|
+
graphql_endpoint: state.config.graphql_endpoint.clone(),
|
|
1044
|
+
generated_at,
|
|
1045
|
+
expires_at_epoch,
|
|
1046
|
+
publication_mission: mission,
|
|
1047
|
+
publication_policy: policy,
|
|
1048
|
+
docs_index,
|
|
1049
|
+
};
|
|
1050
|
+
write_static_prompt_cache(&state.config.cache_root, &cache)?;
|
|
1051
|
+
Ok(cache)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
fn read_static_prompt_cache(root: &Path) -> Result<Option<StaticPromptContext>> {
|
|
1055
|
+
let path = static_prompt_cache_path(root);
|
|
1056
|
+
let text = match fs::read_to_string(&path) {
|
|
1057
|
+
Ok(value) => value,
|
|
1058
|
+
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
|
|
1059
|
+
Err(error) => {
|
|
1060
|
+
return Err(error)
|
|
1061
|
+
.with_context(|| format!("read static prompt cache {}", path.display()));
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
let cache: StaticPromptContext = serde_json::from_str(&text)
|
|
1065
|
+
.with_context(|| format!("parse static prompt cache {}", path.display()))?;
|
|
1066
|
+
Ok(Some(cache))
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
fn write_static_prompt_cache(root: &Path, cache: &StaticPromptContext) -> Result<()> {
|
|
1070
|
+
fs::create_dir_all(root)
|
|
1071
|
+
.with_context(|| format!("create static prompt cache directory {}", root.display()))?;
|
|
1072
|
+
let path = static_prompt_cache_path(root);
|
|
1073
|
+
let tmp_path = path.with_extension("json.tmp");
|
|
1074
|
+
let text = serde_json::to_string(cache)?;
|
|
1075
|
+
fs::write(&tmp_path, text)
|
|
1076
|
+
.with_context(|| format!("write static prompt cache {}", tmp_path.display()))?;
|
|
1077
|
+
fs::rename(&tmp_path, &path)
|
|
1078
|
+
.with_context(|| format!("move static prompt cache {}", path.display()))?;
|
|
1079
|
+
Ok(())
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
fn static_prompt_cache_path(root: &Path) -> PathBuf {
|
|
1083
|
+
root.join("static_prompt_context.json")
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async fn load_publication_doctrine(state: &AppState) -> Result<(String, String)> {
|
|
1087
|
+
let query = r#"
|
|
1088
|
+
query ListCurrentDoctrineItems($versionState: String!, $limit: Int, $nextToken: String) {
|
|
1089
|
+
listItemsByVersionStateAndUpdatedAt(versionState: $versionState, limit: $limit, nextToken: $nextToken) {
|
|
1090
|
+
items {
|
|
1091
|
+
id
|
|
1092
|
+
type
|
|
1093
|
+
typeStatus
|
|
1094
|
+
slug
|
|
1095
|
+
body
|
|
1096
|
+
}
|
|
1097
|
+
nextToken
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
"#;
|
|
1101
|
+
let mut next_token: Option<String> = None;
|
|
1102
|
+
let mut mission = String::new();
|
|
1103
|
+
let mut policy = String::new();
|
|
1104
|
+
loop {
|
|
1105
|
+
let variables = json!({
|
|
1106
|
+
"versionState": "current",
|
|
1107
|
+
"limit": 150,
|
|
1108
|
+
"nextToken": next_token,
|
|
1109
|
+
});
|
|
1110
|
+
let data = match graphql(state, query, variables).await {
|
|
1111
|
+
Ok(value) => value,
|
|
1112
|
+
Err(error) => {
|
|
1113
|
+
warn!(
|
|
1114
|
+
error = %format_error_chain(&error),
|
|
1115
|
+
"failed loading doctrine from GraphQL"
|
|
1116
|
+
);
|
|
1117
|
+
break;
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
let connection = data
|
|
1121
|
+
.get("listItemsByVersionStateAndUpdatedAt")
|
|
1122
|
+
.cloned()
|
|
1123
|
+
.unwrap_or(Value::Null);
|
|
1124
|
+
let items = connection
|
|
1125
|
+
.get("items")
|
|
1126
|
+
.and_then(Value::as_array)
|
|
1127
|
+
.cloned()
|
|
1128
|
+
.unwrap_or_default();
|
|
1129
|
+
for item in items {
|
|
1130
|
+
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
|
|
1131
|
+
if item_type != "doctrine" {
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
let type_status = item
|
|
1135
|
+
.get("typeStatus")
|
|
1136
|
+
.and_then(Value::as_str)
|
|
1137
|
+
.unwrap_or_default();
|
|
1138
|
+
if type_status != "doctrine#private" {
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
let slug = item.get("slug").and_then(Value::as_str).unwrap_or_default();
|
|
1142
|
+
let text = item
|
|
1143
|
+
.get("body")
|
|
1144
|
+
.and_then(Value::as_array)
|
|
1145
|
+
.map(|lines| {
|
|
1146
|
+
lines
|
|
1147
|
+
.iter()
|
|
1148
|
+
.filter_map(Value::as_str)
|
|
1149
|
+
.map(str::trim)
|
|
1150
|
+
.filter(|line| !line.is_empty())
|
|
1151
|
+
.collect::<Vec<&str>>()
|
|
1152
|
+
.join("\n\n")
|
|
1153
|
+
})
|
|
1154
|
+
.unwrap_or_default();
|
|
1155
|
+
if slug == "editorial-doctrine-mission" && !text.trim().is_empty() {
|
|
1156
|
+
mission = text;
|
|
1157
|
+
} else if slug == "editorial-doctrine-policy" && !text.trim().is_empty() {
|
|
1158
|
+
policy = text;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if !mission.is_empty() && !policy.is_empty() {
|
|
1162
|
+
break;
|
|
1163
|
+
}
|
|
1164
|
+
next_token = connection
|
|
1165
|
+
.get("nextToken")
|
|
1166
|
+
.and_then(Value::as_str)
|
|
1167
|
+
.map(ToString::to_string);
|
|
1168
|
+
if next_token.is_none() {
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
Ok((mission, policy))
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
async fn load_execute_tactus_docs_index(state: &AppState) -> Result<Vec<StaticPromptDocSummary>> {
|
|
1176
|
+
let runner_input = json!({
|
|
1177
|
+
"mode": "docs_index"
|
|
1178
|
+
});
|
|
1179
|
+
let output = call_execute_tactus_runner(state, &runner_input).await?;
|
|
1180
|
+
let entries = output
|
|
1181
|
+
.get("entries")
|
|
1182
|
+
.and_then(Value::as_array)
|
|
1183
|
+
.cloned()
|
|
1184
|
+
.unwrap_or_default();
|
|
1185
|
+
let mut docs = Vec::with_capacity(entries.len());
|
|
1186
|
+
for value in entries {
|
|
1187
|
+
let id = value
|
|
1188
|
+
.get("id")
|
|
1189
|
+
.and_then(Value::as_str)
|
|
1190
|
+
.unwrap_or_default()
|
|
1191
|
+
.to_string();
|
|
1192
|
+
if id.is_empty() {
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
docs.push(StaticPromptDocSummary {
|
|
1196
|
+
id,
|
|
1197
|
+
title: value
|
|
1198
|
+
.get("title")
|
|
1199
|
+
.and_then(Value::as_str)
|
|
1200
|
+
.unwrap_or_default()
|
|
1201
|
+
.to_string(),
|
|
1202
|
+
summary: value
|
|
1203
|
+
.get("summary")
|
|
1204
|
+
.and_then(Value::as_str)
|
|
1205
|
+
.unwrap_or_default()
|
|
1206
|
+
.to_string(),
|
|
1207
|
+
namespace: value
|
|
1208
|
+
.get("namespace")
|
|
1209
|
+
.and_then(Value::as_str)
|
|
1210
|
+
.unwrap_or_default()
|
|
1211
|
+
.to_string(),
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
docs.sort_by(|left, right| left.id.cmp(&right.id));
|
|
1215
|
+
Ok(docs)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
#[derive(Debug, Clone)]
|
|
1219
|
+
struct PersistedMessage {
|
|
1220
|
+
id: String,
|
|
1221
|
+
thread_id: String,
|
|
1222
|
+
parent_message_id: Option<String>,
|
|
1223
|
+
created_at: String,
|
|
1224
|
+
sequence_number: i64,
|
|
1225
|
+
role: String,
|
|
1226
|
+
message_kind: String,
|
|
1227
|
+
message_type: String,
|
|
1228
|
+
content: String,
|
|
1229
|
+
summary: String,
|
|
1230
|
+
response_status: Option<String>,
|
|
1231
|
+
response_error: Option<String>,
|
|
1232
|
+
response_started_at: Option<String>,
|
|
1233
|
+
response_completed_at: Option<String>,
|
|
1234
|
+
metadata: Value,
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
struct AssistantStreamWriter<'a> {
|
|
1238
|
+
state: &'a AppState,
|
|
1239
|
+
message_id: String,
|
|
1240
|
+
thread_id: String,
|
|
1241
|
+
message_created_at: String,
|
|
1242
|
+
sequence_number: i64,
|
|
1243
|
+
role: String,
|
|
1244
|
+
message_kind: String,
|
|
1245
|
+
message_type: String,
|
|
1246
|
+
content: String,
|
|
1247
|
+
last_flush_at: DateTime<Utc>,
|
|
1248
|
+
last_flush_len: usize,
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
impl<'a> AssistantStreamWriter<'a> {
|
|
1252
|
+
fn new(state: &'a AppState, message: PersistedMessage) -> Self {
|
|
1253
|
+
Self {
|
|
1254
|
+
state,
|
|
1255
|
+
message_id: message.id,
|
|
1256
|
+
thread_id: message.thread_id,
|
|
1257
|
+
message_created_at: message.created_at,
|
|
1258
|
+
sequence_number: message.sequence_number,
|
|
1259
|
+
role: message.role,
|
|
1260
|
+
message_kind: message.message_kind,
|
|
1261
|
+
message_type: message.message_type,
|
|
1262
|
+
content: message.content,
|
|
1263
|
+
last_flush_at: Utc::now(),
|
|
1264
|
+
last_flush_len: 0,
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async fn push_delta(&mut self, delta: &str) -> Result<()> {
|
|
1269
|
+
if delta.is_empty() {
|
|
1270
|
+
return Ok(());
|
|
1271
|
+
}
|
|
1272
|
+
self.content.push_str(delta);
|
|
1273
|
+
let now = Utc::now();
|
|
1274
|
+
let elapsed_ms = now
|
|
1275
|
+
.signed_duration_since(self.last_flush_at)
|
|
1276
|
+
.num_milliseconds();
|
|
1277
|
+
if elapsed_ms >= STREAM_FLUSH_INTERVAL_MS
|
|
1278
|
+
|| self.content.len().saturating_sub(self.last_flush_len) >= STREAM_FLUSH_CHARS
|
|
1279
|
+
{
|
|
1280
|
+
self.flush().await?;
|
|
1281
|
+
}
|
|
1282
|
+
Ok(())
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
async fn flush(&mut self) -> Result<()> {
|
|
1286
|
+
if self.content.len() == self.last_flush_len {
|
|
1287
|
+
return Ok(());
|
|
1288
|
+
}
|
|
1289
|
+
update_message_graphql(
|
|
1290
|
+
self.state,
|
|
1291
|
+
json!({
|
|
1292
|
+
"id": self.message_id,
|
|
1293
|
+
"threadId": self.thread_id,
|
|
1294
|
+
"createdAt": self.message_created_at,
|
|
1295
|
+
"sequenceNumber": self.sequence_number,
|
|
1296
|
+
"role": self.role,
|
|
1297
|
+
"messageKind": self.message_kind,
|
|
1298
|
+
"messageDomain": MESSAGE_DOMAIN_CONVERSATION,
|
|
1299
|
+
"messageType": self.message_type,
|
|
1300
|
+
"source": "papyrus-console",
|
|
1301
|
+
"content": self.content,
|
|
1302
|
+
"summary": truncate_summary(&self.content),
|
|
1303
|
+
"semanticLayer": CHAT_DETAIL_LAYER,
|
|
1304
|
+
"searchVisibility": EXPLICIT_SEARCH,
|
|
1305
|
+
"responseTarget": DEFAULT_RESPONSE_TARGET,
|
|
1306
|
+
"responseStatus": "RUNNING",
|
|
1307
|
+
"updatedAt": now_iso(),
|
|
1308
|
+
"newsroomFeedKey": NEWSROOM_FEED_CONSOLE_CHAT,
|
|
1309
|
+
}),
|
|
1310
|
+
)
|
|
1311
|
+
.await?;
|
|
1312
|
+
self.last_flush_at = Utc::now();
|
|
1313
|
+
self.last_flush_len = self.content.len();
|
|
1314
|
+
Ok(())
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const MESSAGE_SELECTION: &str = r#"
|
|
1320
|
+
id
|
|
1321
|
+
threadId
|
|
1322
|
+
parentMessageId
|
|
1323
|
+
sequenceNumber
|
|
1324
|
+
role
|
|
1325
|
+
messageKind
|
|
1326
|
+
messageDomain
|
|
1327
|
+
messageType
|
|
1328
|
+
source
|
|
1329
|
+
authorLabel
|
|
1330
|
+
content
|
|
1331
|
+
summary
|
|
1332
|
+
semanticLayer
|
|
1333
|
+
searchVisibility
|
|
1334
|
+
responseTarget
|
|
1335
|
+
responseStatus
|
|
1336
|
+
responseOwner
|
|
1337
|
+
responseStartedAt
|
|
1338
|
+
responseCompletedAt
|
|
1339
|
+
responseError
|
|
1340
|
+
metadata
|
|
1341
|
+
createdAt
|
|
1342
|
+
updatedAt
|
|
1343
|
+
newsroomFeedKey
|
|
1344
|
+
"#;
|
|
1345
|
+
|
|
1346
|
+
const THREAD_SELECTION: &str = r#"
|
|
1347
|
+
id
|
|
1348
|
+
threadKind
|
|
1349
|
+
status
|
|
1350
|
+
title
|
|
1351
|
+
messageCount
|
|
1352
|
+
lastMessageId
|
|
1353
|
+
lastMessageAt
|
|
1354
|
+
contextDigest
|
|
1355
|
+
activeResponseMessageId
|
|
1356
|
+
responseLockOwner
|
|
1357
|
+
responseLockExpiresAt
|
|
1358
|
+
updatedAt
|
|
1359
|
+
"#;
|
|
1360
|
+
|
|
1361
|
+
async fn create_message_graphql(
|
|
1362
|
+
state: &AppState,
|
|
1363
|
+
message: &PersistedMessage,
|
|
1364
|
+
now: &str,
|
|
1365
|
+
) -> Result<()> {
|
|
1366
|
+
let mut input = json!({
|
|
1367
|
+
"id": message.id,
|
|
1368
|
+
"threadId": message.thread_id,
|
|
1369
|
+
"sequenceNumber": message.sequence_number,
|
|
1370
|
+
"role": message.role,
|
|
1371
|
+
"messageKind": message.message_kind,
|
|
1372
|
+
"messageDomain": MESSAGE_DOMAIN_CONVERSATION,
|
|
1373
|
+
"messageType": message.message_type,
|
|
1374
|
+
"status": "active",
|
|
1375
|
+
"summary": message.summary,
|
|
1376
|
+
"content": message.content,
|
|
1377
|
+
"semanticLayer": CHAT_DETAIL_LAYER,
|
|
1378
|
+
"searchVisibility": EXPLICIT_SEARCH,
|
|
1379
|
+
"source": "papyrus-console",
|
|
1380
|
+
"newsroomFeedKey": NEWSROOM_FEED_CONSOLE_CHAT,
|
|
1381
|
+
"createdAt": message.created_at,
|
|
1382
|
+
"updatedAt": now,
|
|
1383
|
+
"metadata": message.metadata.to_string(),
|
|
1384
|
+
});
|
|
1385
|
+
if let Some(parent_id) = &message.parent_message_id {
|
|
1386
|
+
input["parentMessageId"] = Value::String(parent_id.clone());
|
|
1387
|
+
}
|
|
1388
|
+
if let Some(status) = &message.response_status {
|
|
1389
|
+
input["responseStatus"] = Value::String(status.clone());
|
|
1390
|
+
} else {
|
|
1391
|
+
input["responseStatus"] = Value::String("COMPLETED".to_string());
|
|
1392
|
+
}
|
|
1393
|
+
if let Some(error) = &message.response_error {
|
|
1394
|
+
input["responseError"] = Value::String(error.clone());
|
|
1395
|
+
}
|
|
1396
|
+
if let Some(started_at) = &message.response_started_at {
|
|
1397
|
+
input["responseStartedAt"] = Value::String(started_at.clone());
|
|
1398
|
+
}
|
|
1399
|
+
if let Some(completed_at) = &message.response_completed_at {
|
|
1400
|
+
input["responseCompletedAt"] = Value::String(completed_at.clone());
|
|
1401
|
+
}
|
|
1402
|
+
graphql(
|
|
1403
|
+
state,
|
|
1404
|
+
&format!(
|
|
1405
|
+
"mutation CreateMessage($input: CreateMessageInput!) {{ createMessage(input: $input) {{ {MESSAGE_SELECTION} }} }}"
|
|
1406
|
+
),
|
|
1407
|
+
json!({ "input": input }),
|
|
1408
|
+
)
|
|
1409
|
+
.await?;
|
|
1410
|
+
Ok(())
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
async fn update_message_graphql(state: &AppState, input: Value) -> Result<()> {
|
|
1414
|
+
graphql(
|
|
1415
|
+
state,
|
|
1416
|
+
&format!(
|
|
1417
|
+
"mutation UpdateMessage($input: UpdateMessageInput!) {{ updateMessage(input: $input) {{ {MESSAGE_SELECTION} }} }}"
|
|
1418
|
+
),
|
|
1419
|
+
json!({ "input": input }),
|
|
1420
|
+
)
|
|
1421
|
+
.await?;
|
|
1422
|
+
Ok(())
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
async fn publish_message_status(
|
|
1426
|
+
state: &AppState,
|
|
1427
|
+
message: &ChatMessage,
|
|
1428
|
+
status: &str,
|
|
1429
|
+
error: Option<&str>,
|
|
1430
|
+
) -> Result<()> {
|
|
1431
|
+
let mut input = json!({
|
|
1432
|
+
"id": message.id,
|
|
1433
|
+
"threadId": message.thread_id,
|
|
1434
|
+
"createdAt": message.created_at,
|
|
1435
|
+
"sequenceNumber": message.sequence_number,
|
|
1436
|
+
"role": message.role,
|
|
1437
|
+
"messageKind": message.message_kind,
|
|
1438
|
+
"messageDomain": MESSAGE_DOMAIN_CONVERSATION,
|
|
1439
|
+
"messageType": message.message_type,
|
|
1440
|
+
"source": "papyrus-console",
|
|
1441
|
+
"content": message.content,
|
|
1442
|
+
"summary": truncate_summary(&message.content),
|
|
1443
|
+
"semanticLayer": CHAT_DETAIL_LAYER,
|
|
1444
|
+
"searchVisibility": EXPLICIT_SEARCH,
|
|
1445
|
+
"responseTarget": message.response_target,
|
|
1446
|
+
"responseStatus": status,
|
|
1447
|
+
"updatedAt": now_iso(),
|
|
1448
|
+
"newsroomFeedKey": NEWSROOM_FEED_CONSOLE_CHAT,
|
|
1449
|
+
});
|
|
1450
|
+
if status == "RUNNING" {
|
|
1451
|
+
input["responseOwner"] = Value::String("rust-lambda".to_string());
|
|
1452
|
+
input["responseStartedAt"] = Value::String(now_iso());
|
|
1453
|
+
}
|
|
1454
|
+
if matches!(status, "COMPLETED" | "FAILED") {
|
|
1455
|
+
input["responseCompletedAt"] = Value::String(now_iso());
|
|
1456
|
+
}
|
|
1457
|
+
if let Some(error) = error {
|
|
1458
|
+
input["responseError"] = Value::String(error.to_string());
|
|
1459
|
+
}
|
|
1460
|
+
update_message_graphql(state, input).await
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
async fn update_thread_graphql(
|
|
1464
|
+
state: &AppState,
|
|
1465
|
+
thread_id: &str,
|
|
1466
|
+
context: &ThreadContextCache,
|
|
1467
|
+
) -> Result<()> {
|
|
1468
|
+
graphql(
|
|
1469
|
+
state,
|
|
1470
|
+
&format!(
|
|
1471
|
+
"mutation UpdateMessageThread($input: UpdateMessageThreadInput!) {{ updateMessageThread(input: $input) {{ {THREAD_SELECTION} }} }}"
|
|
1472
|
+
),
|
|
1473
|
+
json!({
|
|
1474
|
+
"input": {
|
|
1475
|
+
"id": thread_id,
|
|
1476
|
+
"messageCount": context.last_sequence_number,
|
|
1477
|
+
"lastMessageId": context.last_message_id,
|
|
1478
|
+
"lastMessageAt": context.updated_at,
|
|
1479
|
+
"contextDigest": context.context_digest,
|
|
1480
|
+
"activeResponseMessageId": null,
|
|
1481
|
+
"responseLockOwner": null,
|
|
1482
|
+
"responseLockExpiresAt": null,
|
|
1483
|
+
"updatedAt": now_iso(),
|
|
1484
|
+
}
|
|
1485
|
+
}),
|
|
1486
|
+
)
|
|
1487
|
+
.await?;
|
|
1488
|
+
Ok(())
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
async fn graphql(state: &AppState, query: &str, variables: Value) -> Result<Value> {
|
|
1492
|
+
let body = serde_json::to_vec(&json!({ "query": query, "variables": variables }))
|
|
1493
|
+
.context("serialize AppSync GraphQL request body")?;
|
|
1494
|
+
let credentials = state
|
|
1495
|
+
.credentials_provider
|
|
1496
|
+
.provide_credentials()
|
|
1497
|
+
.await
|
|
1498
|
+
.context("load AWS credentials for AppSync IAM signing")?;
|
|
1499
|
+
let identity = credentials.into();
|
|
1500
|
+
let signable_request = SignableRequest::new(
|
|
1501
|
+
"POST",
|
|
1502
|
+
&state.config.graphql_endpoint,
|
|
1503
|
+
[("content-type", "application/json")].into_iter(),
|
|
1504
|
+
SignableBody::Bytes(&body),
|
|
1505
|
+
)
|
|
1506
|
+
.context("construct signable AppSync request")?;
|
|
1507
|
+
let signing_params = v4::SigningParams::builder()
|
|
1508
|
+
.identity(&identity)
|
|
1509
|
+
.region(&state.aws_region)
|
|
1510
|
+
.name("appsync")
|
|
1511
|
+
.time(SystemTime::now())
|
|
1512
|
+
.settings(SigningSettings::default())
|
|
1513
|
+
.build()
|
|
1514
|
+
.context("build AppSync SigV4 signing params")?
|
|
1515
|
+
.into();
|
|
1516
|
+
let (instructions, _signature) = sign(signable_request, &signing_params)
|
|
1517
|
+
.context("sign AppSync GraphQL request")?
|
|
1518
|
+
.into_parts();
|
|
1519
|
+
let mut signed_request = http::Request::builder()
|
|
1520
|
+
.method("POST")
|
|
1521
|
+
.uri(&state.config.graphql_endpoint)
|
|
1522
|
+
.header("content-type", "application/json")
|
|
1523
|
+
.body(())
|
|
1524
|
+
.context("build AppSync HTTP request for signing")?;
|
|
1525
|
+
instructions.apply_to_request_http1x(&mut signed_request);
|
|
1526
|
+
|
|
1527
|
+
let mut request_builder = state
|
|
1528
|
+
.http
|
|
1529
|
+
.post(&state.config.graphql_endpoint)
|
|
1530
|
+
.body(body);
|
|
1531
|
+
for (name, value) in signed_request.headers() {
|
|
1532
|
+
request_builder = request_builder.header(name, value);
|
|
1533
|
+
}
|
|
1534
|
+
let response = state
|
|
1535
|
+
.http
|
|
1536
|
+
.execute(request_builder.build().context("build signed AppSync request")?)
|
|
1537
|
+
.await
|
|
1538
|
+
.context("send AppSync GraphQL mutation")?;
|
|
1539
|
+
let status = response.status();
|
|
1540
|
+
let payload: Value = response
|
|
1541
|
+
.json()
|
|
1542
|
+
.await
|
|
1543
|
+
.context("parse AppSync GraphQL response")?;
|
|
1544
|
+
if !status.is_success() || payload.get("errors").is_some() {
|
|
1545
|
+
return Err(anyhow!(
|
|
1546
|
+
"AppSync GraphQL mutation failed with {status}: {payload}"
|
|
1547
|
+
));
|
|
1548
|
+
}
|
|
1549
|
+
Ok(payload.get("data").cloned().unwrap_or(Value::Null))
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
#[derive(Debug)]
|
|
1553
|
+
struct AgentTurnOutput {
|
|
1554
|
+
assistant_content: String,
|
|
1555
|
+
model_context: Value,
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
async fn run_agent_turn(
|
|
1559
|
+
state: &AppState,
|
|
1560
|
+
trigger: &ChatMessage,
|
|
1561
|
+
context: &ThreadContextCache,
|
|
1562
|
+
static_prompt: &StaticPromptContext,
|
|
1563
|
+
model: &str,
|
|
1564
|
+
assistant_writer: &mut AssistantStreamWriter<'_>,
|
|
1565
|
+
next_sequence: &mut i64,
|
|
1566
|
+
) -> Result<AgentTurnOutput> {
|
|
1567
|
+
const MAX_TOOL_RETRIES: usize = 3;
|
|
1568
|
+
const MAX_TOOL_ATTEMPTS: usize = MAX_TOOL_RETRIES + 1;
|
|
1569
|
+
let local_test_lane = trigger.response_target == "local" || state.config.response_target == "local";
|
|
1570
|
+
let capture_model_context = metadata_value_field(&trigger.metadata, "captureModelContext")
|
|
1571
|
+
.and_then(|value| value.as_bool())
|
|
1572
|
+
.unwrap_or(false);
|
|
1573
|
+
let require_tool_calls = metadata_value_field(&trigger.metadata, "requireToolCalls")
|
|
1574
|
+
.and_then(|value| value.as_bool())
|
|
1575
|
+
.unwrap_or(false);
|
|
1576
|
+
let required_tool_calls = metadata_string_list_field(&trigger.metadata, "requiredToolCalls");
|
|
1577
|
+
let required_tool_call_counts =
|
|
1578
|
+
metadata_string_usize_map_field(&trigger.metadata, "requiredToolCallCounts");
|
|
1579
|
+
let expected_final_response = metadata_string_field(&trigger.metadata, "expectedFinalResponse");
|
|
1580
|
+
let web_ui = resolve_trigger_web_ui(&trigger.metadata)
|
|
1581
|
+
.or_else(|| resolve_web_ui_from_context_messages(&context.recent_messages));
|
|
1582
|
+
if web_ui.is_none() {
|
|
1583
|
+
if metadata_nested_value_field(&trigger.metadata, &["console"]).is_some() {
|
|
1584
|
+
warn!(
|
|
1585
|
+
message_id = trigger.id,
|
|
1586
|
+
thread_id = trigger.thread_id,
|
|
1587
|
+
"console metadata present but console.webUi missing; deploy the latest Newsroom frontend"
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
} else {
|
|
1591
|
+
info!(
|
|
1592
|
+
message_id = trigger.id,
|
|
1593
|
+
thread_id = trigger.thread_id,
|
|
1594
|
+
web_path = web_ui
|
|
1595
|
+
.as_ref()
|
|
1596
|
+
.and_then(|entry| entry.get("webPath"))
|
|
1597
|
+
.and_then(|entry| entry.as_str())
|
|
1598
|
+
.unwrap_or(""),
|
|
1599
|
+
papyrus_location_uri = web_ui
|
|
1600
|
+
.as_ref()
|
|
1601
|
+
.and_then(|entry| entry.get("papyrusLocationUri"))
|
|
1602
|
+
.and_then(|entry| entry.as_str())
|
|
1603
|
+
.unwrap_or(""),
|
|
1604
|
+
"resolved console webUi metadata for trigger message"
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
let mut messages = build_openai_messages(context, static_prompt, web_ui.as_ref());
|
|
1608
|
+
let mut model_context_attempts: Vec<Value> = Vec::new();
|
|
1609
|
+
let mut saw_tool_calls = false;
|
|
1610
|
+
let mut saw_tool_errors = false;
|
|
1611
|
+
let mut observed_tool_counts: HashMap<String, usize> = HashMap::new();
|
|
1612
|
+
let mut assignment_ids = HashSet::new();
|
|
1613
|
+
let mut last_assistant_content = String::new();
|
|
1614
|
+
|
|
1615
|
+
for round in 0..MAX_TOOL_ATTEMPTS {
|
|
1616
|
+
if capture_model_context {
|
|
1617
|
+
model_context_attempts.push(capture_openai_messages(&messages));
|
|
1618
|
+
}
|
|
1619
|
+
let turn = stream_openai_chat(
|
|
1620
|
+
state,
|
|
1621
|
+
openai_request(model, messages.clone(), require_tool_calls && !saw_tool_calls),
|
|
1622
|
+
Some(&mut *assistant_writer),
|
|
1623
|
+
)
|
|
1624
|
+
.await?;
|
|
1625
|
+
let assistant_content = turn.content;
|
|
1626
|
+
let tool_calls = turn.tool_calls;
|
|
1627
|
+
last_assistant_content = assistant_content.clone();
|
|
1628
|
+
|
|
1629
|
+
if tool_calls.is_empty() {
|
|
1630
|
+
if require_tool_calls && round + 1 < MAX_TOOL_ATTEMPTS {
|
|
1631
|
+
let retries_remaining = MAX_TOOL_ATTEMPTS - (round + 1);
|
|
1632
|
+
if !assistant_content.trim().is_empty() {
|
|
1633
|
+
messages.push(json!({
|
|
1634
|
+
"role": "assistant",
|
|
1635
|
+
"content": assistant_content,
|
|
1636
|
+
}));
|
|
1637
|
+
}
|
|
1638
|
+
let missing_required = missing_required_tool_calls(
|
|
1639
|
+
&required_tool_calls,
|
|
1640
|
+
&required_tool_call_counts,
|
|
1641
|
+
&observed_tool_counts,
|
|
1642
|
+
);
|
|
1643
|
+
let required_hint = if required_tool_calls.is_empty() {
|
|
1644
|
+
"At least one execute_tactus tool call is required before final response."
|
|
1645
|
+
.to_string()
|
|
1646
|
+
} else if missing_required.is_empty() {
|
|
1647
|
+
format!(
|
|
1648
|
+
"Required tool call(s) already observed: {}.",
|
|
1649
|
+
required_tool_calls
|
|
1650
|
+
.iter()
|
|
1651
|
+
.map(|entry| format!("`{entry}`"))
|
|
1652
|
+
.collect::<Vec<String>>()
|
|
1653
|
+
.join(", ")
|
|
1654
|
+
)
|
|
1655
|
+
} else {
|
|
1656
|
+
format!(
|
|
1657
|
+
"Missing required tool call(s): {}.",
|
|
1658
|
+
format_missing_required_calls(&missing_required)
|
|
1659
|
+
)
|
|
1660
|
+
};
|
|
1661
|
+
messages.push(json!({
|
|
1662
|
+
"role": "system",
|
|
1663
|
+
"content": format!(
|
|
1664
|
+
"No tool call was made, but this turn requires tool usage. Retries remaining: {}. {} Do not answer with a sentinel or natural-language final response until required tool call(s) succeed. When calling execute_tactus, pass arguments as {{\"tactus\":\"return ...\"}} and ensure `tactus` is a non-empty Lua string.",
|
|
1665
|
+
retries_remaining,
|
|
1666
|
+
required_hint,
|
|
1667
|
+
),
|
|
1668
|
+
}));
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
if let Some(expected) = deterministic_expected_response(
|
|
1672
|
+
local_test_lane,
|
|
1673
|
+
saw_tool_calls,
|
|
1674
|
+
saw_tool_errors,
|
|
1675
|
+
&expected_final_response,
|
|
1676
|
+
) {
|
|
1677
|
+
return Ok(AgentTurnOutput {
|
|
1678
|
+
assistant_content: expected,
|
|
1679
|
+
model_context: if capture_model_context {
|
|
1680
|
+
json!({ "attempts": model_context_attempts })
|
|
1681
|
+
} else {
|
|
1682
|
+
Value::Null
|
|
1683
|
+
},
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
if let Some(assignment_id) = deterministic_assignment_id(local_test_lane, saw_tool_calls, saw_tool_errors, &assignment_ids) {
|
|
1687
|
+
return Ok(AgentTurnOutput {
|
|
1688
|
+
assistant_content: assignment_id,
|
|
1689
|
+
model_context: if capture_model_context {
|
|
1690
|
+
json!({ "attempts": model_context_attempts })
|
|
1691
|
+
} else {
|
|
1692
|
+
Value::Null
|
|
1693
|
+
},
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
return Ok(AgentTurnOutput {
|
|
1697
|
+
assistant_content: if saw_tool_calls {
|
|
1698
|
+
fallback_tool_assistant_content(assistant_content)
|
|
1699
|
+
} else {
|
|
1700
|
+
fallback_assistant_content(assistant_content)
|
|
1701
|
+
},
|
|
1702
|
+
model_context: if capture_model_context {
|
|
1703
|
+
json!({ "attempts": model_context_attempts })
|
|
1704
|
+
} else {
|
|
1705
|
+
Value::Null
|
|
1706
|
+
},
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
saw_tool_calls = true;
|
|
1711
|
+
messages.push(json!({
|
|
1712
|
+
"role": "assistant",
|
|
1713
|
+
"content": if assistant_content.is_empty() { Value::Null } else { Value::String(assistant_content) },
|
|
1714
|
+
"tool_calls": tool_calls,
|
|
1715
|
+
}));
|
|
1716
|
+
|
|
1717
|
+
let mut round_tool_errors: Vec<(String, String, Option<String>, String)> = Vec::new();
|
|
1718
|
+
for call in tool_calls {
|
|
1719
|
+
let call_id = call
|
|
1720
|
+
.get("id")
|
|
1721
|
+
.and_then(Value::as_str)
|
|
1722
|
+
.unwrap_or("tool-call");
|
|
1723
|
+
let function = call
|
|
1724
|
+
.get("function")
|
|
1725
|
+
.and_then(Value::as_object)
|
|
1726
|
+
.cloned()
|
|
1727
|
+
.unwrap_or_default();
|
|
1728
|
+
let name = function
|
|
1729
|
+
.get("name")
|
|
1730
|
+
.and_then(Value::as_str)
|
|
1731
|
+
.unwrap_or("execute_tactus");
|
|
1732
|
+
let arguments = function
|
|
1733
|
+
.get("arguments")
|
|
1734
|
+
.and_then(Value::as_str)
|
|
1735
|
+
.unwrap_or("{}");
|
|
1736
|
+
let call_now = now_iso();
|
|
1737
|
+
let call_record = PersistedMessage {
|
|
1738
|
+
id: format!("message-console-tool-{}", Uuid::new_v4()),
|
|
1739
|
+
thread_id: trigger.thread_id.clone(),
|
|
1740
|
+
parent_message_id: Some(trigger.id.clone()),
|
|
1741
|
+
created_at: call_now.clone(),
|
|
1742
|
+
sequence_number: *next_sequence,
|
|
1743
|
+
role: "TOOL".to_string(),
|
|
1744
|
+
message_kind: MESSAGE_KIND_TOOL_CALL.to_string(),
|
|
1745
|
+
message_type: "TOOL_CALL".to_string(),
|
|
1746
|
+
content: arguments.to_string(),
|
|
1747
|
+
summary: truncate_summary(&format!("{name} tool call")),
|
|
1748
|
+
response_status: None,
|
|
1749
|
+
response_error: None,
|
|
1750
|
+
response_started_at: None,
|
|
1751
|
+
response_completed_at: None,
|
|
1752
|
+
metadata: json!({ "toolCallId": call_id, "toolName": name, "arguments": arguments }),
|
|
1753
|
+
};
|
|
1754
|
+
create_message_graphql(state, &call_record, &call_now).await?;
|
|
1755
|
+
*next_sequence += 1;
|
|
1756
|
+
|
|
1757
|
+
let tool_result =
|
|
1758
|
+
execute_tactus_tool(state, trigger, context, name, arguments, web_ui.as_ref()).await;
|
|
1759
|
+
let tool_result_markdown = render_tool_result_markdown(&tool_result);
|
|
1760
|
+
collect_assignment_ids(&tool_result, &mut assignment_ids);
|
|
1761
|
+
collect_tool_api_call_counts(&tool_result, &mut observed_tool_counts);
|
|
1762
|
+
let result_now = now_iso();
|
|
1763
|
+
let result_record = PersistedMessage {
|
|
1764
|
+
id: format!("message-console-tool-{}", Uuid::new_v4()),
|
|
1765
|
+
thread_id: trigger.thread_id.clone(),
|
|
1766
|
+
parent_message_id: Some(trigger.id.clone()),
|
|
1767
|
+
created_at: result_now.clone(),
|
|
1768
|
+
sequence_number: *next_sequence,
|
|
1769
|
+
role: "TOOL".to_string(),
|
|
1770
|
+
message_kind: MESSAGE_KIND_TOOL_RESULT.to_string(),
|
|
1771
|
+
message_type: "TOOL_RESPONSE".to_string(),
|
|
1772
|
+
content: tool_result_markdown.clone(),
|
|
1773
|
+
summary: truncate_summary(&format!("{name} tool result")),
|
|
1774
|
+
response_status: None,
|
|
1775
|
+
response_error: None,
|
|
1776
|
+
response_started_at: None,
|
|
1777
|
+
response_completed_at: None,
|
|
1778
|
+
metadata: json!({
|
|
1779
|
+
"toolCallId": call_id,
|
|
1780
|
+
"toolName": name,
|
|
1781
|
+
"toolResultJson": tool_result.clone(),
|
|
1782
|
+
}),
|
|
1783
|
+
};
|
|
1784
|
+
create_message_graphql(state, &result_record, &result_now).await?;
|
|
1785
|
+
*next_sequence += 1;
|
|
1786
|
+
messages.push(json!({
|
|
1787
|
+
"role": "tool",
|
|
1788
|
+
"tool_call_id": call_id,
|
|
1789
|
+
"content": tool_result_markdown,
|
|
1790
|
+
}));
|
|
1791
|
+
if let Some(error_text) = tool_result_error_text(&tool_result) {
|
|
1792
|
+
saw_tool_errors = true;
|
|
1793
|
+
let error_code = tool_result_error_code(&tool_result);
|
|
1794
|
+
round_tool_errors.push((
|
|
1795
|
+
name.to_string(),
|
|
1796
|
+
arguments.to_string(),
|
|
1797
|
+
error_code,
|
|
1798
|
+
error_text,
|
|
1799
|
+
));
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
if !round_tool_errors.is_empty() && round + 1 < MAX_TOOL_ATTEMPTS {
|
|
1804
|
+
let retries_remaining = MAX_TOOL_ATTEMPTS - (round + 1);
|
|
1805
|
+
let unsupported_snippet_seen = round_tool_errors
|
|
1806
|
+
.iter()
|
|
1807
|
+
.any(|(_, _, code, _)| code.as_deref() == Some("unsupported_snippet"));
|
|
1808
|
+
let parse_error_seen = round_tool_errors
|
|
1809
|
+
.iter()
|
|
1810
|
+
.any(|(_, _, _, error)| error.contains("Failed to parse DSL") || error.contains("unfinished string"));
|
|
1811
|
+
let retry_details = round_tool_errors
|
|
1812
|
+
.into_iter()
|
|
1813
|
+
.map(|(name, arguments, code, error)| {
|
|
1814
|
+
let code_prefix = code
|
|
1815
|
+
.as_deref()
|
|
1816
|
+
.map(|value| format!("Code: {}\n", value))
|
|
1817
|
+
.unwrap_or_default();
|
|
1818
|
+
format!(
|
|
1819
|
+
"Tool: {}\nArguments:\n{}\n{}Error:\n{}",
|
|
1820
|
+
name, arguments, code_prefix, error
|
|
1821
|
+
)
|
|
1822
|
+
})
|
|
1823
|
+
.collect::<Vec<String>>()
|
|
1824
|
+
.join("\n\n---\n\n");
|
|
1825
|
+
let correction_hint = if unsupported_snippet_seen {
|
|
1826
|
+
"At least one error was unsupported_snippet: your previous call used JS/object-call shape. \
|
|
1827
|
+
Use Lua/Tactus table-call syntax instead (for example: return docs_get{ id = \"resources.Assignment\" })."
|
|
1828
|
+
} else if parse_error_seen {
|
|
1829
|
+
"At least one call failed with a Lua parse error. Use a single-line snippet, keep braces balanced, \
|
|
1830
|
+
and prefer double-quoted string literals in tactus (for example: return knowledge_query{ semanticQuery = \"...\" })."
|
|
1831
|
+
} else {
|
|
1832
|
+
"Use raw Lua in tactus (no markdown fences, no escaped quotes like \\\" unless the value itself needs it)."
|
|
1833
|
+
};
|
|
1834
|
+
messages.push(json!({
|
|
1835
|
+
"role": "system",
|
|
1836
|
+
"content": format!(
|
|
1837
|
+
"The previous tool call(s) returned error(s). Retry by issuing a corrected execute_tactus tool call. Retries remaining: {}. {} Do not call docs_list/docs_get unless the user explicitly asked for documentation lookup. execute_tactus arguments must be {{\"tactus\":\"return ...\"}} with a non-empty Lua snippet. Previous tool failure detail(s):\n{}",
|
|
1838
|
+
retries_remaining,
|
|
1839
|
+
correction_hint,
|
|
1840
|
+
retry_details,
|
|
1841
|
+
),
|
|
1842
|
+
}));
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
if require_tool_calls && round + 1 < MAX_TOOL_ATTEMPTS && !required_tool_calls.is_empty() {
|
|
1846
|
+
let missing_required = missing_required_tool_calls(
|
|
1847
|
+
&required_tool_calls,
|
|
1848
|
+
&required_tool_call_counts,
|
|
1849
|
+
&observed_tool_counts,
|
|
1850
|
+
);
|
|
1851
|
+
if !missing_required.is_empty() {
|
|
1852
|
+
let retries_remaining = MAX_TOOL_ATTEMPTS - (round + 1);
|
|
1853
|
+
messages.push(json!({
|
|
1854
|
+
"role": "system",
|
|
1855
|
+
"content": format!(
|
|
1856
|
+
"Required tool-call set is incomplete. Retries remaining: {}. Missing required tool call(s): {}. Continue with execute_tactus tool calls for the missing operations before finalizing your response. execute_tactus arguments must be {{\"tactus\":\"return ...\"}} with non-empty Lua code.",
|
|
1857
|
+
retries_remaining,
|
|
1858
|
+
format_missing_required_calls(&missing_required),
|
|
1859
|
+
),
|
|
1860
|
+
}));
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
if let Some(expected) = deterministic_expected_response(
|
|
1866
|
+
local_test_lane,
|
|
1867
|
+
saw_tool_calls,
|
|
1868
|
+
saw_tool_errors,
|
|
1869
|
+
&expected_final_response,
|
|
1870
|
+
) {
|
|
1871
|
+
return Ok(AgentTurnOutput {
|
|
1872
|
+
assistant_content: expected,
|
|
1873
|
+
model_context: if capture_model_context {
|
|
1874
|
+
json!({ "attempts": model_context_attempts })
|
|
1875
|
+
} else {
|
|
1876
|
+
Value::Null
|
|
1877
|
+
},
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
Ok(AgentTurnOutput {
|
|
1881
|
+
assistant_content: deterministic_assignment_id(local_test_lane, saw_tool_calls, saw_tool_errors, &assignment_ids)
|
|
1882
|
+
.unwrap_or_else(|| fallback_tool_assistant_content(last_assistant_content)),
|
|
1883
|
+
model_context: if capture_model_context {
|
|
1884
|
+
json!({ "attempts": model_context_attempts })
|
|
1885
|
+
} else {
|
|
1886
|
+
Value::Null
|
|
1887
|
+
},
|
|
1888
|
+
})
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
fn capture_openai_messages(messages: &[Value]) -> Value {
|
|
1892
|
+
const MAX_CAPTURED_MESSAGES: usize = 16;
|
|
1893
|
+
const MAX_CAPTURED_CONTENT_CHARS: usize = 320;
|
|
1894
|
+
let start = messages.len().saturating_sub(MAX_CAPTURED_MESSAGES);
|
|
1895
|
+
let captured = messages[start..]
|
|
1896
|
+
.iter()
|
|
1897
|
+
.map(|message| {
|
|
1898
|
+
let role = message
|
|
1899
|
+
.get("role")
|
|
1900
|
+
.and_then(Value::as_str)
|
|
1901
|
+
.unwrap_or("unknown");
|
|
1902
|
+
let mut payload = json!({ "role": role });
|
|
1903
|
+
if let Some(content) = message.get("content").and_then(Value::as_str) {
|
|
1904
|
+
if let Some(entry) = payload.as_object_mut() {
|
|
1905
|
+
entry.insert(
|
|
1906
|
+
"content".to_string(),
|
|
1907
|
+
Value::String(truncate_chars(content, MAX_CAPTURED_CONTENT_CHARS)),
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
|
|
1912
|
+
let names = tool_calls
|
|
1913
|
+
.iter()
|
|
1914
|
+
.filter_map(|entry| {
|
|
1915
|
+
entry
|
|
1916
|
+
.get("function")
|
|
1917
|
+
.and_then(|function| function.get("name"))
|
|
1918
|
+
.and_then(Value::as_str)
|
|
1919
|
+
})
|
|
1920
|
+
.map(ToString::to_string)
|
|
1921
|
+
.collect::<Vec<String>>();
|
|
1922
|
+
if !names.is_empty() {
|
|
1923
|
+
if let Some(entry) = payload.as_object_mut() {
|
|
1924
|
+
entry.insert(
|
|
1925
|
+
"toolCallNames".to_string(),
|
|
1926
|
+
Value::Array(names.into_iter().map(Value::String).collect()),
|
|
1927
|
+
);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
if let Some(tool_call_id) = message.get("tool_call_id").and_then(Value::as_str) {
|
|
1932
|
+
if let Some(entry) = payload.as_object_mut() {
|
|
1933
|
+
entry.insert("toolCallId".to_string(), Value::String(tool_call_id.to_string()));
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
payload
|
|
1937
|
+
})
|
|
1938
|
+
.collect::<Vec<Value>>();
|
|
1939
|
+
json!({
|
|
1940
|
+
"messageCount": messages.len(),
|
|
1941
|
+
"capturedCount": captured.len(),
|
|
1942
|
+
"messages": captured,
|
|
1943
|
+
})
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
fn truncate_chars(value: &str, max_chars: usize) -> String {
|
|
1947
|
+
if value.chars().count() <= max_chars {
|
|
1948
|
+
return value.to_string();
|
|
1949
|
+
}
|
|
1950
|
+
let mut output = String::new();
|
|
1951
|
+
for (index, ch) in value.chars().enumerate() {
|
|
1952
|
+
if index >= max_chars {
|
|
1953
|
+
break;
|
|
1954
|
+
}
|
|
1955
|
+
output.push(ch);
|
|
1956
|
+
}
|
|
1957
|
+
output.push_str("…");
|
|
1958
|
+
output
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
fn deterministic_assignment_id(
|
|
1962
|
+
local_test_lane: bool,
|
|
1963
|
+
saw_tool_calls: bool,
|
|
1964
|
+
saw_tool_errors: bool,
|
|
1965
|
+
assignment_ids: &HashSet<String>,
|
|
1966
|
+
) -> Option<String> {
|
|
1967
|
+
if !local_test_lane || !saw_tool_calls || saw_tool_errors || assignment_ids.len() != 1 {
|
|
1968
|
+
return None;
|
|
1969
|
+
}
|
|
1970
|
+
assignment_ids.iter().next().cloned()
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
fn deterministic_expected_response(
|
|
1974
|
+
local_test_lane: bool,
|
|
1975
|
+
saw_tool_calls: bool,
|
|
1976
|
+
saw_tool_errors: bool,
|
|
1977
|
+
expected_final_response: &Option<String>,
|
|
1978
|
+
) -> Option<String> {
|
|
1979
|
+
if !local_test_lane || !saw_tool_calls || saw_tool_errors {
|
|
1980
|
+
return None;
|
|
1981
|
+
}
|
|
1982
|
+
expected_final_response
|
|
1983
|
+
.as_ref()
|
|
1984
|
+
.map(|value| value.trim().to_string())
|
|
1985
|
+
.filter(|value| !value.is_empty())
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
fn missing_required_tool_calls(
|
|
1989
|
+
required_tool_calls: &[String],
|
|
1990
|
+
required_tool_call_counts: &HashMap<String, usize>,
|
|
1991
|
+
observed_tool_counts: &HashMap<String, usize>,
|
|
1992
|
+
) -> Vec<String> {
|
|
1993
|
+
let mut missing = Vec::new();
|
|
1994
|
+
for required in required_tool_calls {
|
|
1995
|
+
let required_count = required_tool_call_counts
|
|
1996
|
+
.get(required)
|
|
1997
|
+
.copied()
|
|
1998
|
+
.unwrap_or(1);
|
|
1999
|
+
let observed = observed_tool_counts
|
|
2000
|
+
.get(required)
|
|
2001
|
+
.copied()
|
|
2002
|
+
.unwrap_or(0);
|
|
2003
|
+
if observed < required_count {
|
|
2004
|
+
if required_count > 1 {
|
|
2005
|
+
missing.push(format!("{required} (need {required_count}, saw {observed})"));
|
|
2006
|
+
} else {
|
|
2007
|
+
missing.push(required.clone());
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
missing
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
fn format_missing_required_calls(missing_required: &[String]) -> String {
|
|
2015
|
+
missing_required
|
|
2016
|
+
.iter()
|
|
2017
|
+
.map(|entry| format!("`{entry}`"))
|
|
2018
|
+
.collect::<Vec<String>>()
|
|
2019
|
+
.join(", ")
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
fn tool_result_error_text(result: &Value) -> Option<String> {
|
|
2023
|
+
let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false);
|
|
2024
|
+
if ok {
|
|
2025
|
+
return None;
|
|
2026
|
+
}
|
|
2027
|
+
let error = result.get("error")?;
|
|
2028
|
+
if error.is_string() {
|
|
2029
|
+
return error.as_str().map(ToString::to_string);
|
|
2030
|
+
}
|
|
2031
|
+
if let Some(error_obj) = error.as_object() {
|
|
2032
|
+
let code = error_obj
|
|
2033
|
+
.get("code")
|
|
2034
|
+
.and_then(Value::as_str)
|
|
2035
|
+
.unwrap_or("unknown_error");
|
|
2036
|
+
let message = error_obj
|
|
2037
|
+
.get("message")
|
|
2038
|
+
.and_then(Value::as_str)
|
|
2039
|
+
.map(ToString::to_string)
|
|
2040
|
+
.unwrap_or_else(|| error.to_string());
|
|
2041
|
+
return Some(format!("{code}: {message}"));
|
|
2042
|
+
}
|
|
2043
|
+
Some(error.to_string())
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
fn tool_result_error_code(result: &Value) -> Option<String> {
|
|
2047
|
+
let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false);
|
|
2048
|
+
if ok {
|
|
2049
|
+
return None;
|
|
2050
|
+
}
|
|
2051
|
+
let error = result.get("error")?;
|
|
2052
|
+
if let Some(error_obj) = error.as_object() {
|
|
2053
|
+
return error_obj
|
|
2054
|
+
.get("code")
|
|
2055
|
+
.and_then(Value::as_str)
|
|
2056
|
+
.map(ToString::to_string);
|
|
2057
|
+
}
|
|
2058
|
+
None
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
fn render_tool_result_markdown(result: &Value) -> String {
|
|
2062
|
+
let mut lines: Vec<String> = Vec::new();
|
|
2063
|
+
if let Some(ok) = result.get("ok").and_then(Value::as_bool) {
|
|
2064
|
+
lines.push(format!("- status: {}", if ok { "ok" } else { "error" }));
|
|
2065
|
+
}
|
|
2066
|
+
if let Some(api_calls) = result.get("api_calls").and_then(Value::as_array) {
|
|
2067
|
+
let calls = api_calls
|
|
2068
|
+
.iter()
|
|
2069
|
+
.filter_map(Value::as_str)
|
|
2070
|
+
.collect::<Vec<&str>>();
|
|
2071
|
+
if !calls.is_empty() {
|
|
2072
|
+
lines.push("- api_calls:".to_string());
|
|
2073
|
+
for call in calls {
|
|
2074
|
+
lines.push(format!(" - `{}`", call));
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
if let Some(error) = result.get("error").and_then(Value::as_object) {
|
|
2079
|
+
lines.push("- error:".to_string());
|
|
2080
|
+
if let Some(code) = error.get("code").and_then(Value::as_str) {
|
|
2081
|
+
lines.push(format!(" - code: `{}`", code));
|
|
2082
|
+
}
|
|
2083
|
+
if let Some(message) = error.get("message").and_then(Value::as_str) {
|
|
2084
|
+
lines.push(format!(" - message: {}", message));
|
|
2085
|
+
}
|
|
2086
|
+
if let Some(retryable) = error.get("retryable").and_then(Value::as_bool) {
|
|
2087
|
+
lines.push(format!(" - retryable: {}", retryable));
|
|
2088
|
+
}
|
|
2089
|
+
if let Some(details) = error.get("details") {
|
|
2090
|
+
lines.push(" - details:".to_string());
|
|
2091
|
+
lines.extend(render_value_markdown(details, 4));
|
|
2092
|
+
}
|
|
2093
|
+
} else if let Some(value) = result.get("value") {
|
|
2094
|
+
lines.push("- value:".to_string());
|
|
2095
|
+
lines.extend(render_value_markdown(value, 2));
|
|
2096
|
+
}
|
|
2097
|
+
if lines.is_empty() {
|
|
2098
|
+
return "- status: empty tool result".to_string();
|
|
2099
|
+
}
|
|
2100
|
+
lines.join("\n")
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
fn render_value_markdown(value: &Value, indent: usize) -> Vec<String> {
|
|
2104
|
+
let pad = " ".repeat(indent);
|
|
2105
|
+
match value {
|
|
2106
|
+
Value::Null => vec![format!("{}- null", pad)],
|
|
2107
|
+
Value::Bool(boolean) => vec![format!("{}- {}", pad, boolean)],
|
|
2108
|
+
Value::Number(number) => vec![format!("{}- {}", pad, number)],
|
|
2109
|
+
Value::String(text) => text
|
|
2110
|
+
.lines()
|
|
2111
|
+
.map(|line| format!("{}- {}", pad, line))
|
|
2112
|
+
.collect(),
|
|
2113
|
+
Value::Array(items) => {
|
|
2114
|
+
if items.is_empty() {
|
|
2115
|
+
return vec![format!("{}- []", pad)];
|
|
2116
|
+
}
|
|
2117
|
+
let mut lines = Vec::new();
|
|
2118
|
+
for item in items {
|
|
2119
|
+
lines.extend(render_value_markdown(item, indent));
|
|
2120
|
+
}
|
|
2121
|
+
lines
|
|
2122
|
+
}
|
|
2123
|
+
Value::Object(map) => {
|
|
2124
|
+
if map.is_empty() {
|
|
2125
|
+
return vec![format!("{}- {{}}", pad)];
|
|
2126
|
+
}
|
|
2127
|
+
let mut lines = Vec::new();
|
|
2128
|
+
let mut keys = map.keys().collect::<Vec<&String>>();
|
|
2129
|
+
keys.sort();
|
|
2130
|
+
for key in keys {
|
|
2131
|
+
let entry = map.get(key).unwrap_or(&Value::Null);
|
|
2132
|
+
match entry {
|
|
2133
|
+
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
|
|
2134
|
+
let rendered = render_value_markdown(entry, 0).join(" ").trim().trim_start_matches('-').trim().to_string();
|
|
2135
|
+
lines.push(format!("{}- **{}**: {}", pad, key, rendered));
|
|
2136
|
+
}
|
|
2137
|
+
_ => {
|
|
2138
|
+
lines.push(format!("{}- **{}**:", pad, key));
|
|
2139
|
+
lines.extend(render_value_markdown(entry, indent + 2));
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
lines
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
fn collect_assignment_ids(value: &Value, assignment_ids: &mut HashSet<String>) {
|
|
2149
|
+
let Some(obj) = value.as_object() else {
|
|
2150
|
+
return;
|
|
2151
|
+
};
|
|
2152
|
+
if let Some(assignment_id) = obj.get("assignmentId").and_then(Value::as_str) {
|
|
2153
|
+
assignment_ids.insert(assignment_id.to_string());
|
|
2154
|
+
}
|
|
2155
|
+
if let Some(assignment) = obj.get("assignment").and_then(Value::as_object) {
|
|
2156
|
+
if let Some(assignment_id) = assignment.get("id").and_then(Value::as_str) {
|
|
2157
|
+
assignment_ids.insert(assignment_id.to_string());
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
for nested in obj.values() {
|
|
2161
|
+
if nested.is_object() {
|
|
2162
|
+
collect_assignment_ids(nested, assignment_ids);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
fn collect_tool_api_call_counts(value: &Value, observed_tool_counts: &mut HashMap<String, usize>) {
|
|
2168
|
+
let calls = value
|
|
2169
|
+
.get("api_calls")
|
|
2170
|
+
.and_then(Value::as_array)
|
|
2171
|
+
.map(|entries| {
|
|
2172
|
+
entries
|
|
2173
|
+
.iter()
|
|
2174
|
+
.filter_map(Value::as_str)
|
|
2175
|
+
.map(str::trim)
|
|
2176
|
+
.filter(|entry| !entry.is_empty())
|
|
2177
|
+
.map(ToString::to_string)
|
|
2178
|
+
.collect::<Vec<String>>()
|
|
2179
|
+
})
|
|
2180
|
+
.unwrap_or_default();
|
|
2181
|
+
for call in calls {
|
|
2182
|
+
*observed_tool_counts.entry(call).or_insert(0) += 1;
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
fn build_openai_messages(
|
|
2187
|
+
context: &ThreadContextCache,
|
|
2188
|
+
static_prompt: &StaticPromptContext,
|
|
2189
|
+
web_ui: Option<&Value>,
|
|
2190
|
+
) -> Vec<Value> {
|
|
2191
|
+
let mut messages = vec![json!({
|
|
2192
|
+
"role": "system",
|
|
2193
|
+
"content": "You are Papyrus, an editorial assistant for an autonomous newsroom. Be concise, accurate, and concrete. Raw console chat turns are working memory and are excluded from default semantic searches unless explicitly requested. When a chat produces durable insight, recommend creating an insight Message instead of making every chat turn canonical knowledge. Use execute_tactus for Papyrus runtime work. Distinguish references from assignments: Reference.* is for scholarly sources; Assignment.* is for newsroom work items (research, curation, intake). When the user provides a URL and asks to add, register, or file a reference, immediately call Reference.create{ url = \"<url>\", title = \"<optional>\", apply = true }—do not use Reference.register, papyrus.reference.create, or Assignment.create unless they asked for research work. For \"most recent references\" or similar, immediately call Reference.list{ limit = <count>, order = \"newest\" } and summarize. For \"last/latest/most recent research assignment\" (or \"research assignment\" without a specific id), immediately call Assignment.list{ type = \"research\", limit = 1 } (includes research.edition-candidate, research.tavily-deep, and other research.* types), then Assignment.get{ id = \"<that id>\" } if the user wants detail—do not use Reference.list and do not list all assignment types (curation.reference-intake is not a research assignment). To create research work, use Assignment.create{ type = \"research\", title = \"...\", apply = true }."
|
|
2194
|
+
})];
|
|
2195
|
+
if !static_prompt.publication_mission.trim().is_empty() {
|
|
2196
|
+
messages.push(json!({
|
|
2197
|
+
"role": "system",
|
|
2198
|
+
"content": format!("Publication mission:\n{}", static_prompt.publication_mission.trim())
|
|
2199
|
+
}));
|
|
2200
|
+
}
|
|
2201
|
+
if !static_prompt.publication_policy.trim().is_empty() {
|
|
2202
|
+
messages.push(json!({
|
|
2203
|
+
"role": "system",
|
|
2204
|
+
"content": format!("Publication policies:\n{}", static_prompt.publication_policy.trim())
|
|
2205
|
+
}));
|
|
2206
|
+
}
|
|
2207
|
+
if !static_prompt.docs_index.is_empty() {
|
|
2208
|
+
let docs_lines = static_prompt
|
|
2209
|
+
.docs_index
|
|
2210
|
+
.iter()
|
|
2211
|
+
.map(|entry| {
|
|
2212
|
+
format!(
|
|
2213
|
+
"- {} [{}]: {}",
|
|
2214
|
+
entry.id,
|
|
2215
|
+
entry.namespace,
|
|
2216
|
+
entry.summary.trim()
|
|
2217
|
+
)
|
|
2218
|
+
})
|
|
2219
|
+
.collect::<Vec<String>>()
|
|
2220
|
+
.join("\n");
|
|
2221
|
+
messages.push(json!({
|
|
2222
|
+
"role": "system",
|
|
2223
|
+
"content": format!(
|
|
2224
|
+
"execute_tactus supports a resource-oriented Papyrus API. Use api_list{{}} for the resource/verb schema. For URL reference intake, call Reference.create{{ url = \"...\", apply = true }}. For recent-reference requests, call Reference.list{{ limit = <count>, order = \"newest\" }} and summarize. For recent research-assignment requests, call Assignment.list{{ type = \"research\", limit = <count> }} (sorted newest-first); optionally Assignment.get{{ id = \"...\" }} for full context. Do not answer assignment questions with Reference.list. Tool responses are markdown only, never JSON. Use docs_list{{ namespace = \"resources\" }} first, then docs_get{{ id = \"resources.Reference\" }} or docs_get{{ id = \"resources.Assignment\" }} before non-trivial writes.\nAvailable doc topics:\n{}",
|
|
2225
|
+
docs_lines
|
|
2226
|
+
)
|
|
2227
|
+
}));
|
|
2228
|
+
}
|
|
2229
|
+
if !context.rolling_summary.trim().is_empty() {
|
|
2230
|
+
messages.push(json!({
|
|
2231
|
+
"role": "system",
|
|
2232
|
+
"content": format!("Thread rolling summary:\n{}", context.rolling_summary)
|
|
2233
|
+
}));
|
|
2234
|
+
}
|
|
2235
|
+
if let Some(web_ui) = web_ui {
|
|
2236
|
+
let location_uri = web_ui
|
|
2237
|
+
.get("papyrusLocationUri")
|
|
2238
|
+
.and_then(Value::as_str)
|
|
2239
|
+
.unwrap_or("")
|
|
2240
|
+
.trim();
|
|
2241
|
+
let web_path = web_ui
|
|
2242
|
+
.get("webPath")
|
|
2243
|
+
.and_then(Value::as_str)
|
|
2244
|
+
.unwrap_or("")
|
|
2245
|
+
.trim();
|
|
2246
|
+
if !location_uri.is_empty() || !web_path.is_empty() {
|
|
2247
|
+
let mut lines = vec![
|
|
2248
|
+
"Current web UI state for this turn (pedantic Papyrus location URIs):".to_string(),
|
|
2249
|
+
];
|
|
2250
|
+
if !location_uri.is_empty() {
|
|
2251
|
+
lines.push(format!("- papyrus_location_uri: {location_uri}"));
|
|
2252
|
+
}
|
|
2253
|
+
if !web_path.is_empty() {
|
|
2254
|
+
lines.push(format!("- web_path: {web_path}"));
|
|
2255
|
+
}
|
|
2256
|
+
if let Some(object_uri) = web_ui
|
|
2257
|
+
.get("papyrusObjectUri")
|
|
2258
|
+
.and_then(Value::as_str)
|
|
2259
|
+
.map(str::trim)
|
|
2260
|
+
.filter(|entry| !entry.is_empty())
|
|
2261
|
+
{
|
|
2262
|
+
lines.push(format!("- focused_object_uri: {object_uri}"));
|
|
2263
|
+
}
|
|
2264
|
+
if let Some(label) = web_ui
|
|
2265
|
+
.get("label")
|
|
2266
|
+
.and_then(Value::as_str)
|
|
2267
|
+
.map(str::trim)
|
|
2268
|
+
.filter(|entry| !entry.is_empty())
|
|
2269
|
+
{
|
|
2270
|
+
lines.push(format!("- label: {label}"));
|
|
2271
|
+
}
|
|
2272
|
+
if let Some(view_mode) = web_ui
|
|
2273
|
+
.get("viewMode")
|
|
2274
|
+
.and_then(Value::as_str)
|
|
2275
|
+
.map(str::trim)
|
|
2276
|
+
.filter(|entry| !entry.is_empty())
|
|
2277
|
+
{
|
|
2278
|
+
lines.push(format!("- view_mode: {view_mode}"));
|
|
2279
|
+
}
|
|
2280
|
+
if let Some(index_filters) = web_ui.get("indexFilters").and_then(Value::as_object) {
|
|
2281
|
+
if !index_filters.is_empty() {
|
|
2282
|
+
lines.push("- index_filters:".to_string());
|
|
2283
|
+
for (key, value) in index_filters {
|
|
2284
|
+
if let Some(text) = value.as_str() {
|
|
2285
|
+
if !text.trim().is_empty() {
|
|
2286
|
+
lines.push(format!(" - {key}: {text}"));
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
lines.push(
|
|
2293
|
+
"Use execute_tactus with papyrus.web.current_location{} to re-read this snapshot, papyrus.web.navigate{ uri = \"papyrus://...\" } to move the browser, and papyrus.web.set_index_filters{ tab = \"references\", status = \"pending\" } (or kind/domain/type) to open a filtered index view.".to_string(),
|
|
2294
|
+
);
|
|
2295
|
+
if let Some(object_uri) = web_ui
|
|
2296
|
+
.get("papyrusObjectUri")
|
|
2297
|
+
.and_then(Value::as_str)
|
|
2298
|
+
.map(str::trim)
|
|
2299
|
+
.filter(|entry| !entry.is_empty())
|
|
2300
|
+
{
|
|
2301
|
+
if object_uri.starts_with("papyrus://reference/") {
|
|
2302
|
+
let reference_id = object_uri.trim_start_matches("papyrus://reference/").trim();
|
|
2303
|
+
if !reference_id.is_empty() {
|
|
2304
|
+
lines.push(format!(
|
|
2305
|
+
"The user is viewing reference detail in the web UI. For requests about \"this reference\", the open page, or what they are looking at, do not ask them to paste the reference. First call execute_tactus with: return papyrus.Reference.get{{ id = \"{reference_id}\" }} (lineage ids resolve to the current version). Then call: return papyrus.knowledge.query{{ semanticQuery = \"Summarize the main claims, methods, and results.\", anchors = {{ {{ uri = \"papyrus://reference/{reference_id}\" }} }} }}. Use double-quoted Lua strings only."
|
|
2306
|
+
));
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
messages.push(json!({
|
|
2311
|
+
"role": "system",
|
|
2312
|
+
"content": lines.join("\n"),
|
|
2313
|
+
}));
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
let mut emitted_tool_call_ids = HashSet::new();
|
|
2317
|
+
for cached in &context.recent_messages {
|
|
2318
|
+
if cached.content.trim().is_empty() {
|
|
2319
|
+
continue;
|
|
2320
|
+
}
|
|
2321
|
+
match cached.role.as_str() {
|
|
2322
|
+
"ASSISTANT" => messages.push(json!({ "role": "assistant", "content": cached.content })),
|
|
2323
|
+
"USER" => messages.push(json!({ "role": "user", "content": cached.content })),
|
|
2324
|
+
"TOOL" if cached.message_kind == MESSAGE_KIND_TOOL_CALL => {
|
|
2325
|
+
let tool_call_id = cached_tool_call_id(cached);
|
|
2326
|
+
emitted_tool_call_ids.insert(tool_call_id);
|
|
2327
|
+
messages.push(json!({
|
|
2328
|
+
"role": "assistant",
|
|
2329
|
+
"content": Value::Null,
|
|
2330
|
+
"tool_calls": [cached_tool_call(cached)],
|
|
2331
|
+
}));
|
|
2332
|
+
}
|
|
2333
|
+
"TOOL" if cached.message_kind == MESSAGE_KIND_TOOL_RESULT => {
|
|
2334
|
+
let tool_call_id = cached_tool_call_id(cached);
|
|
2335
|
+
if !emitted_tool_call_ids.contains(&tool_call_id) {
|
|
2336
|
+
continue;
|
|
2337
|
+
}
|
|
2338
|
+
messages.push(json!({
|
|
2339
|
+
"role": "tool",
|
|
2340
|
+
"tool_call_id": tool_call_id,
|
|
2341
|
+
"content": cached.content,
|
|
2342
|
+
}));
|
|
2343
|
+
}
|
|
2344
|
+
"SYSTEM" if cached.message_kind == MESSAGE_KIND_WEB_CONTEXT => {
|
|
2345
|
+
messages.push(json!({
|
|
2346
|
+
"role": "system",
|
|
2347
|
+
"content": cached.content,
|
|
2348
|
+
}));
|
|
2349
|
+
if let Some(instructions) = console_context_agent_instructions(&cached.metadata) {
|
|
2350
|
+
messages.push(json!({
|
|
2351
|
+
"role": "system",
|
|
2352
|
+
"content": instructions,
|
|
2353
|
+
}));
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
_ => continue,
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
messages
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
fn cached_tool_call(cached: &CachedPromptMessage) -> Value {
|
|
2363
|
+
let arguments = cached
|
|
2364
|
+
.metadata
|
|
2365
|
+
.get("arguments")
|
|
2366
|
+
.and_then(Value::as_str)
|
|
2367
|
+
.unwrap_or(&cached.content);
|
|
2368
|
+
let name = cached
|
|
2369
|
+
.metadata
|
|
2370
|
+
.get("toolName")
|
|
2371
|
+
.and_then(Value::as_str)
|
|
2372
|
+
.unwrap_or("execute_tactus");
|
|
2373
|
+
json!({
|
|
2374
|
+
"id": cached_tool_call_id(cached),
|
|
2375
|
+
"type": "function",
|
|
2376
|
+
"function": {
|
|
2377
|
+
"name": name,
|
|
2378
|
+
"arguments": arguments,
|
|
2379
|
+
}
|
|
2380
|
+
})
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
fn cached_tool_call_id(cached: &CachedPromptMessage) -> String {
|
|
2384
|
+
cached
|
|
2385
|
+
.metadata
|
|
2386
|
+
.get("toolCallId")
|
|
2387
|
+
.and_then(Value::as_str)
|
|
2388
|
+
.map(ToString::to_string)
|
|
2389
|
+
.unwrap_or_else(|| format!("cached-tool-call-{}", cached.id))
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
fn openai_request(model: &str, messages: Vec<Value>, require_tool_calls: bool) -> Value {
|
|
2393
|
+
let mut payload = json!({
|
|
2394
|
+
"model": model,
|
|
2395
|
+
"messages": messages,
|
|
2396
|
+
"parallel_tool_calls": false,
|
|
2397
|
+
"tools": [
|
|
2398
|
+
{
|
|
2399
|
+
"type": "function",
|
|
2400
|
+
"function": {
|
|
2401
|
+
"name": "execute_tactus",
|
|
2402
|
+
"description": "Execute a short Tactus snippet inside the Papyrus newsroom runtime. The tactus argument must be raw Lua (no markdown fences, no JSON-style escaped quotes such as \\\" for normal Lua strings). Use api_list and docs_list/docs_get for progressive documentation discovery. Reference and Assignment resources support create/get/list (Assignment also update). Canonical examples: return Reference.create{ url = \"https://example.com/article\", apply = true }; return Assignment.create{ type = \"research\", title = \"Live smoke assignment\", apply = true }; return Assignment.get{ id = \"assignment-123\" }.",
|
|
2403
|
+
"parameters": {
|
|
2404
|
+
"type": "object",
|
|
2405
|
+
"properties": {
|
|
2406
|
+
"tactus": { "type": "string" },
|
|
2407
|
+
"harness": { "type": "string" },
|
|
2408
|
+
"assignment_id": { "type": "string" },
|
|
2409
|
+
"assignment_item_json": { "type": "string" },
|
|
2410
|
+
"corpus_key": { "type": "string" },
|
|
2411
|
+
"max_evidence_items": { "type": "integer" },
|
|
2412
|
+
"research_mode": { "type": "string" }
|
|
2413
|
+
},
|
|
2414
|
+
"required": ["tactus"]
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
],
|
|
2419
|
+
"tool_choice": "auto"
|
|
2420
|
+
});
|
|
2421
|
+
if require_tool_calls {
|
|
2422
|
+
payload["tool_choice"] = Value::String("required".to_string());
|
|
2423
|
+
}
|
|
2424
|
+
payload
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
#[derive(Debug)]
|
|
2428
|
+
struct StreamedChatMessage {
|
|
2429
|
+
content: String,
|
|
2430
|
+
tool_calls: Vec<Value>,
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
#[derive(Debug, Default, Clone)]
|
|
2434
|
+
struct ToolCallAccumulator {
|
|
2435
|
+
id: Option<String>,
|
|
2436
|
+
call_type: Option<String>,
|
|
2437
|
+
function_name: Option<String>,
|
|
2438
|
+
function_arguments: String,
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
async fn stream_openai_chat(
|
|
2442
|
+
state: &AppState,
|
|
2443
|
+
mut body: Value,
|
|
2444
|
+
mut assistant_writer: Option<&mut AssistantStreamWriter<'_>>,
|
|
2445
|
+
) -> Result<StreamedChatMessage> {
|
|
2446
|
+
body["stream"] = Value::Bool(true);
|
|
2447
|
+
let response = state
|
|
2448
|
+
.http
|
|
2449
|
+
.post("https://api.openai.com/v1/chat/completions")
|
|
2450
|
+
.bearer_auth(&state.openai_api_key)
|
|
2451
|
+
.json(&body)
|
|
2452
|
+
.send()
|
|
2453
|
+
.await
|
|
2454
|
+
.context("send OpenAI streaming chat completion")?;
|
|
2455
|
+
let status = response.status();
|
|
2456
|
+
if !status.is_success() {
|
|
2457
|
+
let payload = response
|
|
2458
|
+
.text()
|
|
2459
|
+
.await
|
|
2460
|
+
.unwrap_or_else(|_| "unable to read OpenAI error body".to_string());
|
|
2461
|
+
return Err(anyhow!(
|
|
2462
|
+
"OpenAI chat completion failed with {status}: {payload}"
|
|
2463
|
+
));
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
let mut stream = response.bytes_stream();
|
|
2467
|
+
let mut buffer = String::new();
|
|
2468
|
+
let mut content = String::new();
|
|
2469
|
+
let mut tool_calls: Vec<ToolCallAccumulator> = Vec::new();
|
|
2470
|
+
|
|
2471
|
+
while let Some(chunk) = stream.next().await {
|
|
2472
|
+
let chunk = chunk.context("read OpenAI streaming chunk")?;
|
|
2473
|
+
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
|
2474
|
+
while let Some(index) = buffer.find("\n\n") {
|
|
2475
|
+
let frame = buffer[..index].to_string();
|
|
2476
|
+
buffer = buffer[index + 2..].to_string();
|
|
2477
|
+
process_openai_sse_frame(
|
|
2478
|
+
&frame,
|
|
2479
|
+
&mut content,
|
|
2480
|
+
&mut tool_calls,
|
|
2481
|
+
assistant_writer.as_deref_mut(),
|
|
2482
|
+
)
|
|
2483
|
+
.await?;
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
if !buffer.trim().is_empty() {
|
|
2487
|
+
process_openai_sse_frame(
|
|
2488
|
+
&buffer,
|
|
2489
|
+
&mut content,
|
|
2490
|
+
&mut tool_calls,
|
|
2491
|
+
assistant_writer.as_deref_mut(),
|
|
2492
|
+
)
|
|
2493
|
+
.await?;
|
|
2494
|
+
}
|
|
2495
|
+
if let Some(writer) = assistant_writer.as_deref_mut() {
|
|
2496
|
+
writer.flush().await?;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
Ok(StreamedChatMessage {
|
|
2500
|
+
content,
|
|
2501
|
+
tool_calls: tool_calls
|
|
2502
|
+
.into_iter()
|
|
2503
|
+
.filter_map(tool_call_accumulator_to_value)
|
|
2504
|
+
.collect(),
|
|
2505
|
+
})
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
async fn process_openai_sse_frame(
|
|
2509
|
+
frame: &str,
|
|
2510
|
+
content: &mut String,
|
|
2511
|
+
tool_calls: &mut Vec<ToolCallAccumulator>,
|
|
2512
|
+
mut assistant_writer: Option<&mut AssistantStreamWriter<'_>>,
|
|
2513
|
+
) -> Result<()> {
|
|
2514
|
+
for line in frame.lines() {
|
|
2515
|
+
let Some(data) = line.strip_prefix("data:") else {
|
|
2516
|
+
continue;
|
|
2517
|
+
};
|
|
2518
|
+
let data = data.trim();
|
|
2519
|
+
if data.is_empty() || data == "[DONE]" {
|
|
2520
|
+
continue;
|
|
2521
|
+
}
|
|
2522
|
+
let payload: Value = serde_json::from_str(data)
|
|
2523
|
+
.with_context(|| format!("parse OpenAI streaming event: {data}"))?;
|
|
2524
|
+
let Some(delta) = payload
|
|
2525
|
+
.get("choices")
|
|
2526
|
+
.and_then(Value::as_array)
|
|
2527
|
+
.and_then(|choices| choices.first())
|
|
2528
|
+
.and_then(|choice| choice.get("delta"))
|
|
2529
|
+
else {
|
|
2530
|
+
continue;
|
|
2531
|
+
};
|
|
2532
|
+
if let Some(text) = delta.get("content").and_then(Value::as_str) {
|
|
2533
|
+
content.push_str(text);
|
|
2534
|
+
if let Some(writer) = assistant_writer.as_deref_mut() {
|
|
2535
|
+
writer.push_delta(text).await?;
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
|
|
2539
|
+
merge_tool_call_deltas(tool_calls, calls);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
Ok(())
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
fn merge_tool_call_deltas(tool_calls: &mut Vec<ToolCallAccumulator>, calls: &[Value]) {
|
|
2546
|
+
for call in calls {
|
|
2547
|
+
let index = call.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
|
2548
|
+
while tool_calls.len() <= index {
|
|
2549
|
+
tool_calls.push(ToolCallAccumulator::default());
|
|
2550
|
+
}
|
|
2551
|
+
let entry = &mut tool_calls[index];
|
|
2552
|
+
if let Some(id) = call.get("id").and_then(Value::as_str) {
|
|
2553
|
+
entry.id = Some(id.to_string());
|
|
2554
|
+
}
|
|
2555
|
+
if let Some(call_type) = call.get("type").and_then(Value::as_str) {
|
|
2556
|
+
entry.call_type = Some(call_type.to_string());
|
|
2557
|
+
}
|
|
2558
|
+
if let Some(function) = call.get("function").and_then(Value::as_object) {
|
|
2559
|
+
if let Some(name) = function.get("name").and_then(Value::as_str) {
|
|
2560
|
+
entry.function_name = Some(name.to_string());
|
|
2561
|
+
}
|
|
2562
|
+
if let Some(arguments) = function.get("arguments").and_then(Value::as_str) {
|
|
2563
|
+
entry.function_arguments.push_str(arguments);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
fn tool_call_accumulator_to_value(call: ToolCallAccumulator) -> Option<Value> {
|
|
2570
|
+
let id = call.id?;
|
|
2571
|
+
let function_name = call.function_name?;
|
|
2572
|
+
Some(json!({
|
|
2573
|
+
"id": id,
|
|
2574
|
+
"type": call.call_type.unwrap_or_else(|| "function".to_string()),
|
|
2575
|
+
"function": {
|
|
2576
|
+
"name": function_name,
|
|
2577
|
+
"arguments": call.function_arguments,
|
|
2578
|
+
}
|
|
2579
|
+
}))
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
fn fallback_assistant_content(content: String) -> String {
|
|
2583
|
+
if content.trim().is_empty() {
|
|
2584
|
+
"I could not generate a response.".to_string()
|
|
2585
|
+
} else {
|
|
2586
|
+
content
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
fn fallback_tool_assistant_content(content: String) -> String {
|
|
2591
|
+
if content.trim().is_empty() {
|
|
2592
|
+
"I used the available Papyrus context but could not generate a final response.".to_string()
|
|
2593
|
+
} else {
|
|
2594
|
+
content
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
async fn execute_tactus_tool(
|
|
2599
|
+
state: &AppState,
|
|
2600
|
+
trigger: &ChatMessage,
|
|
2601
|
+
context: &ThreadContextCache,
|
|
2602
|
+
name: &str,
|
|
2603
|
+
arguments: &str,
|
|
2604
|
+
web_ui: Option<&Value>,
|
|
2605
|
+
) -> Value {
|
|
2606
|
+
if name != "execute_tactus" {
|
|
2607
|
+
return json!({ "ok": false, "error": format!("Unsupported tool {name}") });
|
|
2608
|
+
}
|
|
2609
|
+
let parsed_args: Value = serde_json::from_str(arguments).unwrap_or_else(|_| json!({}));
|
|
2610
|
+
let mut tool_input = json!({
|
|
2611
|
+
"mode": "execute_tactus",
|
|
2612
|
+
"arguments": parsed_args,
|
|
2613
|
+
"thread_context": {
|
|
2614
|
+
"threadId": trigger.thread_id,
|
|
2615
|
+
"triggerMessageId": trigger.id,
|
|
2616
|
+
"triggerSequenceNumber": trigger.sequence_number,
|
|
2617
|
+
"cacheDigest": context.context_digest,
|
|
2618
|
+
"cachedRecentMessageCount": context.recent_messages.len(),
|
|
2619
|
+
"lastCachedSequenceNumber": context.last_sequence_number,
|
|
2620
|
+
}
|
|
2621
|
+
});
|
|
2622
|
+
if let Some(web_ui) = web_ui {
|
|
2623
|
+
if let Some(object) = tool_input.as_object_mut() {
|
|
2624
|
+
object.insert("webUi".to_string(), web_ui.clone());
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
match call_execute_tactus_runner(state, &tool_input).await {
|
|
2628
|
+
Ok(value) => normalize_execute_tactus_result(value),
|
|
2629
|
+
Err(error) => json!({
|
|
2630
|
+
"ok": false,
|
|
2631
|
+
"error": {
|
|
2632
|
+
"code": "runner_failed",
|
|
2633
|
+
"message": error.to_string(),
|
|
2634
|
+
"retryable": true
|
|
2635
|
+
}
|
|
2636
|
+
}),
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
fn normalize_execute_tactus_result(value: Value) -> Value {
|
|
2641
|
+
let Some(error_obj) = value.get("error").and_then(Value::as_object) else {
|
|
2642
|
+
return value;
|
|
2643
|
+
};
|
|
2644
|
+
let code = error_obj
|
|
2645
|
+
.get("code")
|
|
2646
|
+
.and_then(Value::as_str)
|
|
2647
|
+
.unwrap_or("");
|
|
2648
|
+
let message = error_obj
|
|
2649
|
+
.get("message")
|
|
2650
|
+
.and_then(Value::as_str)
|
|
2651
|
+
.unwrap_or("");
|
|
2652
|
+
let reference_lookup_failure = message.contains("Reference not found")
|
|
2653
|
+
|| message.contains("Sandbox error: Reference not found");
|
|
2654
|
+
let parse_like = !reference_lookup_failure
|
|
2655
|
+
&& (message.contains("Failed to parse DSL")
|
|
2656
|
+
|| message.contains("error loading code")
|
|
2657
|
+
|| message.contains("unfinished string")
|
|
2658
|
+
|| message.contains("'end' expected")
|
|
2659
|
+
|| message.contains("unexpected symbol near")
|
|
2660
|
+
|| message.contains("tactus must be a non-empty string"));
|
|
2661
|
+
if !parse_like || (code != "tactus_execution_failed" && code != "invalid_request") {
|
|
2662
|
+
return value;
|
|
2663
|
+
}
|
|
2664
|
+
json!({
|
|
2665
|
+
"ok": false,
|
|
2666
|
+
"error": {
|
|
2667
|
+
"code": "unsupported_snippet",
|
|
2668
|
+
"message": format!(
|
|
2669
|
+
"Snippet rejected: execute_tactus requires valid Lua/Tactus table-call syntax in `tactus`. {}",
|
|
2670
|
+
message
|
|
2671
|
+
),
|
|
2672
|
+
"retryable": true,
|
|
2673
|
+
"details": {
|
|
2674
|
+
"contractVersion": "execute_tactus_snippet_contract_v1",
|
|
2675
|
+
"acceptedSyntaxExamples": [
|
|
2676
|
+
"return docs_get{ id = \"resources.Reference\" }",
|
|
2677
|
+
"return Reference.create{ url = \"https://example.com/article\", apply = true }",
|
|
2678
|
+
"return Assignment.create{ type = \"research\", title = \"...\", apply = true }"
|
|
2679
|
+
],
|
|
2680
|
+
"guidance": "Use non-empty Lua in `tactus`; avoid JS object-call syntax and malformed escaping."
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
})
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
async fn call_execute_tactus_runner(state: &AppState, input: &Value) -> Result<Value> {
|
|
2687
|
+
let mut command = Command::new("python3");
|
|
2688
|
+
command
|
|
2689
|
+
.arg(&state.config.execute_tactus_runner)
|
|
2690
|
+
.env("PYTHONUNBUFFERED", "1")
|
|
2691
|
+
.stdin(std::process::Stdio::piped())
|
|
2692
|
+
.stdout(std::process::Stdio::piped())
|
|
2693
|
+
.stderr(std::process::Stdio::piped());
|
|
2694
|
+
let mut child = command
|
|
2695
|
+
.spawn()
|
|
2696
|
+
.with_context(|| {
|
|
2697
|
+
format!(
|
|
2698
|
+
"spawn execute_tactus runner {}",
|
|
2699
|
+
state.config.execute_tactus_runner.display()
|
|
2700
|
+
)
|
|
2701
|
+
})?;
|
|
2702
|
+
let encoded =
|
|
2703
|
+
serde_json::to_vec(input).context("serialize execute_tactus runner payload to JSON")?;
|
|
2704
|
+
if let Some(mut stdin) = child.stdin.take() {
|
|
2705
|
+
stdin
|
|
2706
|
+
.write_all(&encoded)
|
|
2707
|
+
.await
|
|
2708
|
+
.context("write execute_tactus runner payload")?;
|
|
2709
|
+
}
|
|
2710
|
+
let output = timeout(
|
|
2711
|
+
TokioDuration::from_secs(state.config.execute_tactus_timeout_seconds),
|
|
2712
|
+
child.wait_with_output(),
|
|
2713
|
+
)
|
|
2714
|
+
.await
|
|
2715
|
+
.with_context(|| {
|
|
2716
|
+
format!(
|
|
2717
|
+
"execute_tactus runner timed out after {} seconds",
|
|
2718
|
+
state.config.execute_tactus_timeout_seconds
|
|
2719
|
+
)
|
|
2720
|
+
})?
|
|
2721
|
+
.context("wait for execute_tactus runner")?;
|
|
2722
|
+
if !output.status.success() {
|
|
2723
|
+
return Err(anyhow!(
|
|
2724
|
+
"execute_tactus runner exited with {}: {}",
|
|
2725
|
+
output.status,
|
|
2726
|
+
String::from_utf8_lossy(&output.stderr)
|
|
2727
|
+
));
|
|
2728
|
+
}
|
|
2729
|
+
let stdout = String::from_utf8(output.stdout)
|
|
2730
|
+
.context("parse execute_tactus runner stdout as utf8")?;
|
|
2731
|
+
if stdout.trim().is_empty() {
|
|
2732
|
+
return Err(anyhow!("execute_tactus runner returned empty stdout"));
|
|
2733
|
+
}
|
|
2734
|
+
serde_json::from_str(stdout.trim())
|
|
2735
|
+
.with_context(|| format!("parse execute_tactus runner JSON: {}", stdout.trim()))
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
async fn load_openai_api_key(ssm: &SsmClient) -> Result<String> {
|
|
2739
|
+
if let Some(value) = optional_env("OPENAI_API_KEY") {
|
|
2740
|
+
return Ok(value.trim().to_string());
|
|
2741
|
+
}
|
|
2742
|
+
let response = ssm
|
|
2743
|
+
.get_parameter()
|
|
2744
|
+
.name(SHARED_OPENAI_API_KEY_SSM_PARAM)
|
|
2745
|
+
.with_decryption(true)
|
|
2746
|
+
.send()
|
|
2747
|
+
.await
|
|
2748
|
+
.context("load OpenAI API key from shared SSM parameter")?;
|
|
2749
|
+
response
|
|
2750
|
+
.parameter()
|
|
2751
|
+
.and_then(|parameter| parameter.value())
|
|
2752
|
+
.map(|value| value.trim().to_string())
|
|
2753
|
+
.filter(|value| !value.is_empty())
|
|
2754
|
+
.ok_or_else(|| anyhow!("SSM parameter {SHARED_OPENAI_API_KEY_SSM_PARAM} did not include a value"))
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
fn appsync_region_from_endpoint(endpoint: &str) -> Option<String> {
|
|
2758
|
+
let host = endpoint
|
|
2759
|
+
.trim()
|
|
2760
|
+
.trim_start_matches("https://")
|
|
2761
|
+
.trim_start_matches("http://")
|
|
2762
|
+
.split('/')
|
|
2763
|
+
.next()?;
|
|
2764
|
+
let parts: Vec<&str> = host.split('.').collect();
|
|
2765
|
+
let idx = parts.iter().position(|part| *part == "appsync-api")?;
|
|
2766
|
+
parts.get(idx + 1).map(|value| value.to_string())
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
fn resolve_message_model(default_model: &str, message: &ChatMessage) -> String {
|
|
2770
|
+
let selected = metadata_string_field(&message.metadata, "model")
|
|
2771
|
+
.or_else(|| metadata_string_field(&message.metadata, "selectedModel"))
|
|
2772
|
+
.unwrap_or_else(|| default_model.to_string());
|
|
2773
|
+
if SUPPORTED_CONSOLE_MODELS
|
|
2774
|
+
.iter()
|
|
2775
|
+
.any(|candidate| *candidate == selected)
|
|
2776
|
+
{
|
|
2777
|
+
selected
|
|
2778
|
+
} else {
|
|
2779
|
+
default_model.to_string()
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
fn metadata_string_field(metadata: &Value, key: &str) -> Option<String> {
|
|
2784
|
+
metadata_value_field(metadata, key)
|
|
2785
|
+
.and_then(|value| value.as_str().map(|entry| entry.trim().to_string()))
|
|
2786
|
+
.filter(|entry| !entry.is_empty())
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
fn metadata_i64_field(metadata: &Value, key: &str) -> Option<i64> {
|
|
2790
|
+
metadata_value_field(metadata, key).and_then(|value| value.as_i64())
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
fn metadata_string_list_field(metadata: &Value, key: &str) -> Vec<String> {
|
|
2794
|
+
let Some(value) = metadata_value_field(metadata, key) else {
|
|
2795
|
+
return Vec::new();
|
|
2796
|
+
};
|
|
2797
|
+
let Some(entries) = value.as_array() else {
|
|
2798
|
+
return Vec::new();
|
|
2799
|
+
};
|
|
2800
|
+
entries
|
|
2801
|
+
.iter()
|
|
2802
|
+
.filter_map(Value::as_str)
|
|
2803
|
+
.map(str::trim)
|
|
2804
|
+
.filter(|entry| !entry.is_empty())
|
|
2805
|
+
.map(ToString::to_string)
|
|
2806
|
+
.collect()
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
fn metadata_string_usize_map_field(metadata: &Value, key: &str) -> HashMap<String, usize> {
|
|
2810
|
+
let Some(value) = metadata_value_field(metadata, key) else {
|
|
2811
|
+
return HashMap::new();
|
|
2812
|
+
};
|
|
2813
|
+
let Some(entries) = value.as_object() else {
|
|
2814
|
+
return HashMap::new();
|
|
2815
|
+
};
|
|
2816
|
+
entries
|
|
2817
|
+
.iter()
|
|
2818
|
+
.filter_map(|(entry_key, entry_value)| {
|
|
2819
|
+
let trimmed = entry_key.trim();
|
|
2820
|
+
if trimmed.is_empty() {
|
|
2821
|
+
return None;
|
|
2822
|
+
}
|
|
2823
|
+
let numeric = entry_value
|
|
2824
|
+
.as_u64()
|
|
2825
|
+
.or_else(|| entry_value.as_i64().and_then(|value| u64::try_from(value).ok()))
|
|
2826
|
+
.and_then(|value| usize::try_from(value).ok())?;
|
|
2827
|
+
if numeric == 0 {
|
|
2828
|
+
return None;
|
|
2829
|
+
}
|
|
2830
|
+
Some((trimmed.to_string(), numeric))
|
|
2831
|
+
})
|
|
2832
|
+
.collect()
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
fn metadata_value_field(metadata: &Value, key: &str) -> Option<Value> {
|
|
2836
|
+
if let Some(value) = metadata.get(key) {
|
|
2837
|
+
return Some(value.clone());
|
|
2838
|
+
}
|
|
2839
|
+
let text = metadata.as_str()?;
|
|
2840
|
+
let parsed: Value = serde_json::from_str(text).ok()?;
|
|
2841
|
+
parsed.get(key).cloned()
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
fn coerce_metadata_object(value: Value) -> Option<Value> {
|
|
2845
|
+
match value {
|
|
2846
|
+
Value::Object(_) => Some(value),
|
|
2847
|
+
Value::String(text) => serde_json::from_str(text.trim()).ok(),
|
|
2848
|
+
_ => None,
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
fn metadata_nested_value_field(metadata: &Value, path: &[&str]) -> Option<Value> {
|
|
2853
|
+
if path.is_empty() {
|
|
2854
|
+
return None;
|
|
2855
|
+
}
|
|
2856
|
+
let mut current = coerce_metadata_object(metadata_value_field(metadata, path[0])?)?;
|
|
2857
|
+
for key in path.iter().skip(1) {
|
|
2858
|
+
let entry = current.get(*key)?.clone();
|
|
2859
|
+
current = coerce_metadata_object(entry)?;
|
|
2860
|
+
}
|
|
2861
|
+
Some(current)
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
fn console_context_agent_instructions(metadata: &Value) -> Option<String> {
|
|
2865
|
+
let console = coerce_metadata_object(metadata_value_field(metadata, "console")?)?;
|
|
2866
|
+
console
|
|
2867
|
+
.get("agentInstructions")
|
|
2868
|
+
.and_then(Value::as_str)
|
|
2869
|
+
.map(str::trim)
|
|
2870
|
+
.filter(|entry| !entry.is_empty())
|
|
2871
|
+
.map(str::to_string)
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
fn resolve_web_ui_from_context_messages(messages: &[CachedPromptMessage]) -> Option<Value> {
|
|
2875
|
+
messages
|
|
2876
|
+
.iter()
|
|
2877
|
+
.rev()
|
|
2878
|
+
.filter(|entry| entry.role == "SYSTEM" && entry.message_kind == MESSAGE_KIND_WEB_CONTEXT)
|
|
2879
|
+
.find_map(|entry| resolve_trigger_web_ui(&entry.metadata))
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
fn resolve_trigger_web_ui(metadata: &Value) -> Option<Value> {
|
|
2883
|
+
metadata_nested_value_field(metadata, &["console", "webUi"])
|
|
2884
|
+
.or_else(|| metadata_nested_value_field(metadata, &["webUi"]))
|
|
2885
|
+
.and_then(|value| {
|
|
2886
|
+
let web_path = value
|
|
2887
|
+
.get("webPath")
|
|
2888
|
+
.and_then(Value::as_str)
|
|
2889
|
+
.map(str::trim)
|
|
2890
|
+
.filter(|entry| !entry.is_empty());
|
|
2891
|
+
let location_uri = value
|
|
2892
|
+
.get("papyrusLocationUri")
|
|
2893
|
+
.and_then(Value::as_str)
|
|
2894
|
+
.map(str::trim)
|
|
2895
|
+
.filter(|entry| !entry.is_empty());
|
|
2896
|
+
if web_path.is_some() || location_uri.is_some() {
|
|
2897
|
+
Some(value)
|
|
2898
|
+
} else {
|
|
2899
|
+
None
|
|
2900
|
+
}
|
|
2901
|
+
})
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
fn dynamodb_json_to_value(value: &Value) -> Value {
|
|
2905
|
+
if let Some(s) = value.get("S").and_then(Value::as_str) {
|
|
2906
|
+
return Value::String(s.to_string());
|
|
2907
|
+
}
|
|
2908
|
+
if let Some(n) = value.get("N").and_then(Value::as_str) {
|
|
2909
|
+
return n
|
|
2910
|
+
.parse::<i64>()
|
|
2911
|
+
.map(Value::from)
|
|
2912
|
+
.unwrap_or_else(|_| Value::String(n.to_string()));
|
|
2913
|
+
}
|
|
2914
|
+
if let Some(b) = value.get("BOOL").and_then(Value::as_bool) {
|
|
2915
|
+
return Value::Bool(b);
|
|
2916
|
+
}
|
|
2917
|
+
if value.get("NULL").and_then(Value::as_bool).unwrap_or(false) {
|
|
2918
|
+
return Value::Null;
|
|
2919
|
+
}
|
|
2920
|
+
if let Some(map) = value.get("M").and_then(Value::as_object) {
|
|
2921
|
+
return Value::Object(
|
|
2922
|
+
map.iter()
|
|
2923
|
+
.map(|(key, entry)| (key.clone(), dynamodb_json_to_value(entry)))
|
|
2924
|
+
.collect(),
|
|
2925
|
+
);
|
|
2926
|
+
}
|
|
2927
|
+
if let Some(list) = value.get("L").and_then(Value::as_array) {
|
|
2928
|
+
return Value::Array(list.iter().map(dynamodb_json_to_value).collect());
|
|
2929
|
+
}
|
|
2930
|
+
Value::Null
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
fn stream_string(image: &serde_json::Map<String, Value>, key: &str) -> Result<String> {
|
|
2934
|
+
image
|
|
2935
|
+
.get(key)
|
|
2936
|
+
.map(dynamodb_json_to_value)
|
|
2937
|
+
.and_then(|value| value.as_str().map(str::to_string))
|
|
2938
|
+
.filter(|value| !value.trim().is_empty())
|
|
2939
|
+
.ok_or_else(|| anyhow!("missing DynamoDB stream string field {key}"))
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
fn stream_i64(image: &serde_json::Map<String, Value>, key: &str) -> Option<i64> {
|
|
2943
|
+
image
|
|
2944
|
+
.get(key)
|
|
2945
|
+
.map(dynamodb_json_to_value)
|
|
2946
|
+
.and_then(|value| value.as_i64())
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
fn attr_string(value: Option<&AttributeValue>) -> Option<String> {
|
|
2950
|
+
value.and_then(|attr| attr.as_s().ok().cloned())
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
fn attr_json(value: Option<&AttributeValue>) -> Value {
|
|
2954
|
+
let Some(attr) = value else {
|
|
2955
|
+
return Value::Null;
|
|
2956
|
+
};
|
|
2957
|
+
if let Ok(text) = attr.as_s() {
|
|
2958
|
+
return serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone()));
|
|
2959
|
+
}
|
|
2960
|
+
if let Ok(map) = attr.as_m() {
|
|
2961
|
+
return Value::Object(
|
|
2962
|
+
map.iter()
|
|
2963
|
+
.map(|(key, entry)| (key.clone(), attr_json(Some(entry))))
|
|
2964
|
+
.collect(),
|
|
2965
|
+
);
|
|
2966
|
+
}
|
|
2967
|
+
if let Ok(list) = attr.as_l() {
|
|
2968
|
+
return Value::Array(list.iter().map(|entry| attr_json(Some(entry))).collect());
|
|
2969
|
+
}
|
|
2970
|
+
if let Ok(n) = attr.as_n() {
|
|
2971
|
+
return n
|
|
2972
|
+
.parse::<i64>()
|
|
2973
|
+
.map(Value::from)
|
|
2974
|
+
.or_else(|_| n.parse::<f64>().map(Value::from))
|
|
2975
|
+
.unwrap_or_else(|_| Value::String(n.clone()));
|
|
2976
|
+
}
|
|
2977
|
+
if let Ok(value) = attr.as_bool() {
|
|
2978
|
+
return Value::Bool(*value);
|
|
2979
|
+
}
|
|
2980
|
+
Value::Null
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
fn attr_i64(value: Option<&AttributeValue>) -> Option<i64> {
|
|
2984
|
+
value
|
|
2985
|
+
.and_then(|attr| attr.as_n().ok())
|
|
2986
|
+
.and_then(|value| value.parse::<i64>().ok())
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
fn av_s(value: &str) -> AttributeValue {
|
|
2990
|
+
AttributeValue::S(value.to_string())
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
fn truncate_summary(value: &str) -> String {
|
|
2994
|
+
let text = value.trim().replace('\n', " ");
|
|
2995
|
+
if text.chars().count() <= 180 {
|
|
2996
|
+
return text;
|
|
2997
|
+
}
|
|
2998
|
+
format!("{}…", text.chars().take(179).collect::<String>())
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
fn now_iso() -> String {
|
|
3002
|
+
Utc::now().to_rfc3339()
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
fn required_env(name: &str) -> String {
|
|
3006
|
+
optional_env(name).unwrap_or_else(|| panic!("{name} is required"))
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
fn env_or(name: &str, fallback: &str) -> String {
|
|
3010
|
+
optional_env(name).unwrap_or_else(|| fallback.to_string())
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
fn optional_env(name: &str) -> Option<String> {
|
|
3014
|
+
std::env::var(name)
|
|
3015
|
+
.ok()
|
|
3016
|
+
.map(|value| value.trim().to_string())
|
|
3017
|
+
.filter(|value| !value.is_empty())
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
fn normalize_jwt(token: String) -> String {
|
|
3021
|
+
token
|
|
3022
|
+
.trim()
|
|
3023
|
+
.trim_start_matches("Bearer ")
|
|
3024
|
+
.trim_start_matches("bearer ")
|
|
3025
|
+
.trim()
|
|
3026
|
+
.to_string()
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
fn format_error_chain(error: &anyhow::Error) -> String {
|
|
3030
|
+
error
|
|
3031
|
+
.chain()
|
|
3032
|
+
.map(ToString::to_string)
|
|
3033
|
+
.collect::<Vec<_>>()
|
|
3034
|
+
.join(": ")
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
fn to_hex(bytes: &[u8]) -> String {
|
|
3038
|
+
let mut output = String::with_capacity(bytes.len() * 2);
|
|
3039
|
+
for byte in bytes {
|
|
3040
|
+
output.push_str(&format!("{byte:02x}"));
|
|
3041
|
+
}
|
|
3042
|
+
output
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
#[cfg(test)]
|
|
3046
|
+
mod tests {
|
|
3047
|
+
use super::*;
|
|
3048
|
+
|
|
3049
|
+
#[test]
|
|
3050
|
+
fn context_cache_validation_accepts_matching_previous_cursor() {
|
|
3051
|
+
let cache = ThreadContextCache {
|
|
3052
|
+
schema_version: CONTEXT_CACHE_SCHEMA_VERSION,
|
|
3053
|
+
thread_id: "thread-1".to_string(),
|
|
3054
|
+
last_sequence_number: 2,
|
|
3055
|
+
last_message_id: "message-2".to_string(),
|
|
3056
|
+
context_digest: "digest".to_string(),
|
|
3057
|
+
rolling_summary: String::new(),
|
|
3058
|
+
recent_messages: Vec::new(),
|
|
3059
|
+
updated_at: now_iso(),
|
|
3060
|
+
};
|
|
3061
|
+
let message = ChatMessage {
|
|
3062
|
+
id: "message-3".to_string(),
|
|
3063
|
+
thread_id: "thread-1".to_string(),
|
|
3064
|
+
role: "USER".to_string(),
|
|
3065
|
+
message_kind: MESSAGE_KIND_CHAT_TURN.to_string(),
|
|
3066
|
+
message_type: "MESSAGE".to_string(),
|
|
3067
|
+
content: "Hello".to_string(),
|
|
3068
|
+
response_target: DEFAULT_RESPONSE_TARGET.to_string(),
|
|
3069
|
+
response_status: "PENDING".to_string(),
|
|
3070
|
+
sequence_number: 3,
|
|
3071
|
+
created_at: now_iso(),
|
|
3072
|
+
metadata: json!({ "previousSequenceNumber": 2, "previousContextDigest": "digest" }),
|
|
3073
|
+
};
|
|
3074
|
+
assert!(cache_is_valid_for_message(&cache, &message));
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
#[test]
|
|
3078
|
+
fn resolve_trigger_web_ui_reads_nested_console_metadata() {
|
|
3079
|
+
let metadata = json!({
|
|
3080
|
+
"console": {
|
|
3081
|
+
"author": { "email": "editor@example.com" },
|
|
3082
|
+
"webUi": {
|
|
3083
|
+
"webPath": "/newsroom/references?status=pending",
|
|
3084
|
+
"papyrusLocationUri": "papyrus://newsroom/references/index/status/pending",
|
|
3085
|
+
"viewMode": "index"
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
});
|
|
3089
|
+
let web_ui = resolve_trigger_web_ui(&metadata).expect("webUi");
|
|
3090
|
+
assert_eq!(
|
|
3091
|
+
web_ui.get("webPath").and_then(Value::as_str),
|
|
3092
|
+
Some("/newsroom/references?status=pending")
|
|
3093
|
+
);
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
#[test]
|
|
3097
|
+
fn resolve_trigger_web_ui_parses_stringified_console_metadata() {
|
|
3098
|
+
let metadata = json!({
|
|
3099
|
+
"console": serde_json::to_string(&json!({
|
|
3100
|
+
"author": { "email": "editor@example.com" },
|
|
3101
|
+
"webUi": {
|
|
3102
|
+
"webPath": "/newsroom/messages",
|
|
3103
|
+
"papyrusLocationUri": "papyrus://newsroom/messages/index"
|
|
3104
|
+
}
|
|
3105
|
+
}))
|
|
3106
|
+
.expect("console json")
|
|
3107
|
+
});
|
|
3108
|
+
let web_ui = resolve_trigger_web_ui(&metadata).expect("webUi");
|
|
3109
|
+
assert_eq!(
|
|
3110
|
+
web_ui.get("papyrusLocationUri").and_then(Value::as_str),
|
|
3111
|
+
Some("papyrus://newsroom/messages/index")
|
|
3112
|
+
);
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
#[test]
|
|
3116
|
+
fn resolve_trigger_web_ui_rejects_empty_location_snapshot() {
|
|
3117
|
+
let metadata = json!({
|
|
3118
|
+
"console": {
|
|
3119
|
+
"webUi": {
|
|
3120
|
+
"webPath": "",
|
|
3121
|
+
"papyrusLocationUri": ""
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
});
|
|
3125
|
+
assert!(resolve_trigger_web_ui(&metadata).is_none());
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
#[test]
|
|
3129
|
+
fn resolve_web_ui_from_context_messages_reads_latest_system_context() {
|
|
3130
|
+
let messages = vec![
|
|
3131
|
+
CachedPromptMessage {
|
|
3132
|
+
id: "ctx-1".to_string(),
|
|
3133
|
+
sequence_number: 1,
|
|
3134
|
+
role: "SYSTEM".to_string(),
|
|
3135
|
+
message_kind: MESSAGE_KIND_WEB_CONTEXT.to_string(),
|
|
3136
|
+
message_type: "MESSAGE".to_string(),
|
|
3137
|
+
content: "older".to_string(),
|
|
3138
|
+
metadata: json!({
|
|
3139
|
+
"console": {
|
|
3140
|
+
"webUi": {
|
|
3141
|
+
"webPath": "/newsroom/references",
|
|
3142
|
+
"papyrusLocationUri": "papyrus://newsroom/references/index"
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
}),
|
|
3146
|
+
},
|
|
3147
|
+
CachedPromptMessage {
|
|
3148
|
+
id: "ctx-2".to_string(),
|
|
3149
|
+
sequence_number: 2,
|
|
3150
|
+
role: "SYSTEM".to_string(),
|
|
3151
|
+
message_kind: MESSAGE_KIND_WEB_CONTEXT.to_string(),
|
|
3152
|
+
message_type: "MESSAGE".to_string(),
|
|
3153
|
+
content: "newer".to_string(),
|
|
3154
|
+
metadata: json!({
|
|
3155
|
+
"console": {
|
|
3156
|
+
"webUi": {
|
|
3157
|
+
"webPath": "/newsroom/references/ref-1",
|
|
3158
|
+
"papyrusLocationUri": "papyrus://newsroom/references/detail/ref-1",
|
|
3159
|
+
"papyrusObjectUri": "papyrus://reference/ref-1"
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
}),
|
|
3163
|
+
},
|
|
3164
|
+
];
|
|
3165
|
+
let web_ui = resolve_web_ui_from_context_messages(&messages).expect("webUi");
|
|
3166
|
+
assert_eq!(
|
|
3167
|
+
web_ui.get("papyrusObjectUri").and_then(Value::as_str),
|
|
3168
|
+
Some("papyrus://reference/ref-1")
|
|
3169
|
+
);
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
#[test]
|
|
3173
|
+
fn build_openai_messages_includes_system_web_context_turns() {
|
|
3174
|
+
let context = ThreadContextCache {
|
|
3175
|
+
schema_version: CONTEXT_CACHE_SCHEMA_VERSION,
|
|
3176
|
+
thread_id: "thread-1".to_string(),
|
|
3177
|
+
last_sequence_number: 1,
|
|
3178
|
+
last_message_id: "ctx-1".to_string(),
|
|
3179
|
+
context_digest: String::new(),
|
|
3180
|
+
rolling_summary: String::new(),
|
|
3181
|
+
recent_messages: vec![CachedPromptMessage {
|
|
3182
|
+
id: "ctx-1".to_string(),
|
|
3183
|
+
sequence_number: 1,
|
|
3184
|
+
role: "SYSTEM".to_string(),
|
|
3185
|
+
message_kind: MESSAGE_KIND_WEB_CONTEXT.to_string(),
|
|
3186
|
+
message_type: "MESSAGE".to_string(),
|
|
3187
|
+
content: "Console session started.".to_string(),
|
|
3188
|
+
metadata: json!({
|
|
3189
|
+
"console": {
|
|
3190
|
+
"agentInstructions": "Use Reference.get for this page."
|
|
3191
|
+
}
|
|
3192
|
+
}),
|
|
3193
|
+
}],
|
|
3194
|
+
updated_at: now_iso(),
|
|
3195
|
+
};
|
|
3196
|
+
let static_prompt = StaticPromptContext {
|
|
3197
|
+
schema_version: STATIC_PROMPT_CACHE_SCHEMA_VERSION,
|
|
3198
|
+
graphql_endpoint: String::new(),
|
|
3199
|
+
generated_at: String::new(),
|
|
3200
|
+
expires_at_epoch: 0,
|
|
3201
|
+
publication_mission: String::new(),
|
|
3202
|
+
publication_policy: String::new(),
|
|
3203
|
+
docs_index: Vec::new(),
|
|
3204
|
+
};
|
|
3205
|
+
let messages = build_openai_messages(&context, &static_prompt, None);
|
|
3206
|
+
let system_contents: Vec<&str> = messages
|
|
3207
|
+
.iter()
|
|
3208
|
+
.filter(|entry| entry.get("role").and_then(Value::as_str) == Some("system"))
|
|
3209
|
+
.filter_map(|entry| entry.get("content").and_then(Value::as_str))
|
|
3210
|
+
.collect();
|
|
3211
|
+
assert!(system_contents.iter().any(|entry| entry.contains("Console session started.")));
|
|
3212
|
+
assert!(system_contents.iter().any(|entry| entry.contains("Reference.get")));
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
#[test]
|
|
3216
|
+
fn context_cache_validation_rejects_stale_cursor() {
|
|
3217
|
+
let cache = ThreadContextCache {
|
|
3218
|
+
schema_version: CONTEXT_CACHE_SCHEMA_VERSION,
|
|
3219
|
+
thread_id: "thread-1".to_string(),
|
|
3220
|
+
last_sequence_number: 1,
|
|
3221
|
+
last_message_id: "message-1".to_string(),
|
|
3222
|
+
context_digest: "digest".to_string(),
|
|
3223
|
+
rolling_summary: String::new(),
|
|
3224
|
+
recent_messages: Vec::new(),
|
|
3225
|
+
updated_at: now_iso(),
|
|
3226
|
+
};
|
|
3227
|
+
let message = ChatMessage {
|
|
3228
|
+
id: "message-3".to_string(),
|
|
3229
|
+
thread_id: "thread-1".to_string(),
|
|
3230
|
+
role: "USER".to_string(),
|
|
3231
|
+
message_kind: MESSAGE_KIND_CHAT_TURN.to_string(),
|
|
3232
|
+
message_type: "MESSAGE".to_string(),
|
|
3233
|
+
content: "Hello".to_string(),
|
|
3234
|
+
response_target: DEFAULT_RESPONSE_TARGET.to_string(),
|
|
3235
|
+
response_status: "PENDING".to_string(),
|
|
3236
|
+
sequence_number: 3,
|
|
3237
|
+
created_at: now_iso(),
|
|
3238
|
+
metadata: json!({ "previousSequenceNumber": 2, "previousContextDigest": "digest" }),
|
|
3239
|
+
};
|
|
3240
|
+
assert!(!cache_is_valid_for_message(&cache, &message));
|
|
3241
|
+
}
|
|
3242
|
+
}
|