@anthusai/papyrus 0.0.0 → 1.0.0-next.2

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.
Files changed (254) hide show
  1. package/LICENSE +21 -0
  2. package/amplify/auth/resource.js +61 -0
  3. package/amplify/auth/resource.ts +65 -0
  4. package/amplify/data/resource.js +2037 -0
  5. package/amplify/data/resource.ts +2148 -0
  6. package/amplify/data/schema.js +1850 -0
  7. package/amplify/data/schema.ts +1953 -0
  8. package/amplify/functions/assignment-action/handler.ts +1416 -0
  9. package/amplify/functions/assignment-action/resource.js +8 -0
  10. package/amplify/functions/assignment-action/resource.ts +9 -0
  11. package/amplify/functions/category-action/handler.ts +2259 -0
  12. package/amplify/functions/category-action/resource.js +8 -0
  13. package/amplify/functions/category-action/resource.ts +9 -0
  14. package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
  15. package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
  16. package/amplify/functions/console-chat-responder/Dockerfile +19 -0
  17. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
  18. package/amplify/functions/console-chat-responder/resource.js +153 -0
  19. package/amplify/functions/console-chat-responder/resource.ts +179 -0
  20. package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
  21. package/amplify/functions/email-submission-processor/handler.py +46 -0
  22. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  23. package/amplify/functions/email-submission-processor/resource.js +56 -0
  24. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  25. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  26. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  27. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  28. package/amplify/functions/knowledge-query/handler.py +1 -0
  29. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  30. package/amplify/functions/knowledge-query/resource.js +50 -0
  31. package/amplify/functions/knowledge-query/resource.ts +61 -0
  32. package/amplify/functions/manage-user-role/handler.ts +799 -0
  33. package/amplify/functions/manage-user-role/resource.js +8 -0
  34. package/amplify/functions/manage-user-role/resource.ts +9 -0
  35. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  36. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  37. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  38. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  39. package/amplify/functions/newsroom-summary/resource.js +8 -0
  40. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  41. package/amplify/functions/procedure-action/handler.ts +621 -0
  42. package/amplify/functions/procedure-action/resource.js +8 -0
  43. package/amplify/functions/procedure-action/resource.ts +9 -0
  44. package/amplify/functions/reader-settings/handler.ts +322 -0
  45. package/amplify/functions/reader-settings/resource.js +8 -0
  46. package/amplify/functions/reader-settings/resource.ts +9 -0
  47. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  48. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  49. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  50. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  51. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  52. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  53. package/amplify/functions/shared/email-submission.ts +415 -0
  54. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  55. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  56. package/amplify/functions/shared/model-payloads.ts +186 -0
  57. package/amplify/functions/shared/python-bundle.js +115 -0
  58. package/amplify/functions/shared/python-bundle.ts +141 -0
  59. package/amplify/functions/shared/slack-events.ts +111 -0
  60. package/amplify/functions/slack-delivery/handler.py +112 -0
  61. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  62. package/amplify/functions/slack-delivery/resource.js +38 -0
  63. package/amplify/functions/slack-delivery/resource.ts +51 -0
  64. package/amplify/functions/slack-events/handler.ts +155 -0
  65. package/amplify/functions/slack-events/resource.js +11 -0
  66. package/amplify/functions/slack-events/resource.ts +12 -0
  67. package/amplify/inbound-email/stack.js +46 -0
  68. package/amplify/inbound-email/stack.ts +61 -0
  69. package/amplify/package.json +3 -0
  70. package/amplify/site-backend-config.js +1 -0
  71. package/amplify/site-backend-config.ts +30 -0
  72. package/amplify/site-backend.js +587 -0
  73. package/amplify/site-backend.ts +704 -0
  74. package/amplify/storage/resource.js +18 -0
  75. package/amplify/storage/resource.ts +19 -0
  76. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  77. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  78. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  79. package/app/[year]/[month]/[day]/page.tsx +22 -0
  80. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  81. package/app/api/archive/editions/route.ts +18 -0
  82. package/app/api/media/[...path]/route.ts +35 -0
  83. package/app/api/revalidate/route.ts +94 -0
  84. package/app/archive/page.tsx +24 -0
  85. package/app/articles/[slug]/page.tsx +35 -0
  86. package/app/globals.css +10329 -0
  87. package/app/layout.tsx +146 -0
  88. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  89. package/app/newsroom/[section]/page.tsx +47 -0
  90. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  91. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  92. package/app/newsroom/insights/page.tsx +14 -0
  93. package/app/newsroom/layout.tsx +12 -0
  94. package/app/newsroom/page.tsx +31 -0
  95. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  96. package/app/page.tsx +272 -0
  97. package/app/settings/page.tsx +14 -0
  98. package/app/tailwind.css +65 -0
  99. package/bin/papyrus-app.mjs +10 -0
  100. package/bin/papyrus-infra.mjs +39 -0
  101. package/components/ai-elements/conversation.tsx +168 -0
  102. package/components/ai-elements/message.tsx +360 -0
  103. package/components/ai-elements/model-selector.tsx +30 -0
  104. package/components/ai-elements/prompt-input.tsx +1461 -0
  105. package/components/ai-elements/shimmer.tsx +76 -0
  106. package/components/ai-elements/suggestion.tsx +54 -0
  107. package/components/ai-elements/tool.tsx +173 -0
  108. package/components/amplify-client-provider.tsx +140 -0
  109. package/components/archive-grid.tsx +89 -0
  110. package/components/archive-shell.tsx +41 -0
  111. package/components/article-video.tsx +47 -0
  112. package/components/blog-page-background.tsx +20 -0
  113. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  114. package/components/news-desk-client-provider.tsx +211 -0
  115. package/components/news-desk-page.tsx +119 -0
  116. package/components/news-desk-taxonomy-client.ts +3684 -0
  117. package/components/newspaper.tsx +1929 -0
  118. package/components/newsroom-assignments-view.tsx +664 -0
  119. package/components/newsroom-ops-overview.tsx +226 -0
  120. package/components/newsroom-ops-shell.tsx +319 -0
  121. package/components/newsroom-references-view.tsx +598 -0
  122. package/components/newsroom-topics-view.tsx +425 -0
  123. package/components/papyrus-console-shell.tsx +1912 -0
  124. package/components/pictogram-figure.tsx +65 -0
  125. package/components/presentation-footer.tsx +146 -0
  126. package/components/reader-auth-control.tsx +162 -0
  127. package/components/reader-auth-state.ts +122 -0
  128. package/components/reader-settings.ts +332 -0
  129. package/components/reader-theme-control.tsx +85 -0
  130. package/components/reference-source-preview.tsx +163 -0
  131. package/components/settings-page.tsx +239 -0
  132. package/components/topic-steering-workspace.tsx +18063 -0
  133. package/components/ui/badge.tsx +30 -0
  134. package/components/ui/button-group.tsx +87 -0
  135. package/components/ui/button.tsx +58 -0
  136. package/components/ui/card.tsx +32 -0
  137. package/components/ui/command.tsx +194 -0
  138. package/components/ui/dialog.tsx +158 -0
  139. package/components/ui/dropdown-menu.tsx +266 -0
  140. package/components/ui/hover-card.tsx +51 -0
  141. package/components/ui/input-group.tsx +158 -0
  142. package/components/ui/input.tsx +20 -0
  143. package/components/ui/scroll-area.tsx +16 -0
  144. package/components/ui/select.tsx +199 -0
  145. package/components/ui/separator.tsx +23 -0
  146. package/components/ui/sheet.tsx +92 -0
  147. package/components/ui/spinner.tsx +10 -0
  148. package/components/ui/tabs.tsx +113 -0
  149. package/components/ui/textarea.tsx +18 -0
  150. package/components/ui/tooltip.tsx +66 -0
  151. package/components/use-resolved-papyrus-theme.ts +47 -0
  152. package/infra/amplify-app-shell.js +109 -0
  153. package/infra/amplify-app-shell.ts +155 -0
  154. package/infra/index.js +12 -0
  155. package/lib/amplify-outputs-guard.ts +24 -0
  156. package/lib/amplify-outputs-path.ts +16 -0
  157. package/lib/amplify-server-runtime.ts +63 -0
  158. package/lib/archive-data.ts +30 -0
  159. package/lib/archive-types.ts +15 -0
  160. package/lib/articles.ts +320 -0
  161. package/lib/cached-content-repository.ts +89 -0
  162. package/lib/category-dashboard.ts +37 -0
  163. package/lib/category-repository.ts +1907 -0
  164. package/lib/console-chat-client.ts +1316 -0
  165. package/lib/console-web-context.ts +118 -0
  166. package/lib/content-load-timing.ts +23 -0
  167. package/lib/content-repository.ts +40 -0
  168. package/lib/content-types.ts +104 -0
  169. package/lib/define-site.js +3 -0
  170. package/lib/define-site.ts +21 -0
  171. package/lib/demo-amplify-outputs.ts +15 -0
  172. package/lib/dev-sandbox-editor-auth.ts +18 -0
  173. package/lib/doctrine.ts +128 -0
  174. package/lib/edition-routes.ts +155 -0
  175. package/lib/edition-sections.ts +111 -0
  176. package/lib/empty-edition-layout-plan.ts +109 -0
  177. package/lib/excerpts.ts +8 -0
  178. package/lib/graphql-content-repository.ts +986 -0
  179. package/lib/image-url.ts +10 -0
  180. package/lib/layout-plan.ts +981 -0
  181. package/lib/layout-scenarios.ts +564 -0
  182. package/lib/markus-body.ts +168 -0
  183. package/lib/markus-ir.ts +235 -0
  184. package/lib/markus-projection.ts +243 -0
  185. package/lib/markus-to-article.ts +93 -0
  186. package/lib/news-desk-session.ts +160 -0
  187. package/lib/newspaper-layout.ts +3459 -0
  188. package/lib/newsroom-assignments.ts +173 -0
  189. package/lib/newsroom-base-path.ts +48 -0
  190. package/lib/newsroom-card-layout.ts +102 -0
  191. package/lib/newsroom-category-drilldown.ts +133 -0
  192. package/lib/newsroom-demo-dashboard.ts +267 -0
  193. package/lib/newsroom-demo-profile.ts +46 -0
  194. package/lib/newsroom-forum-routes.ts +153 -0
  195. package/lib/newsroom-index-filters.ts +202 -0
  196. package/lib/newsroom-knowledge-query-request.ts +51 -0
  197. package/lib/newsroom-list-selection.ts +42 -0
  198. package/lib/newsroom-nav.ts +42 -0
  199. package/lib/newsroom-references.ts +116 -0
  200. package/lib/newsroom-sections.ts +52 -0
  201. package/lib/newsroom-topics.ts +118 -0
  202. package/lib/papyrus-object-kinds.ts +24 -0
  203. package/lib/papyrus-web-locations.ts +480 -0
  204. package/lib/pilobol-sample.ts +167 -0
  205. package/lib/presentation-footer.ts +92 -0
  206. package/lib/pretext-layout.ts +179 -0
  207. package/lib/public-placeholder-config.ts +102 -0
  208. package/lib/publication-items.ts +181 -0
  209. package/lib/reader-route-config.ts +1 -0
  210. package/lib/reader-static-params.ts +56 -0
  211. package/lib/reader-storage-url.ts +63 -0
  212. package/lib/reference-display.ts +74 -0
  213. package/lib/reference-policy.ts +97 -0
  214. package/lib/reference-source-preview.ts +175 -0
  215. package/lib/renderer-config.ts +50 -0
  216. package/lib/renderer.ts +87 -0
  217. package/lib/reporting-story-budget.ts +638 -0
  218. package/lib/semantic-graph.ts +554 -0
  219. package/lib/site-brand-tenant.ts +41 -0
  220. package/lib/site-brand.ts +179 -0
  221. package/lib/site-renderer.ts +17 -0
  222. package/lib/site-stack.ts +195 -0
  223. package/lib/themed-image.ts +30 -0
  224. package/lib/utils.ts +6 -0
  225. package/middleware.ts +78 -0
  226. package/package.json +296 -1
  227. package/publications/anth_us/brand.ts +68 -0
  228. package/publications/anth_us/theme.css +66 -0
  229. package/publications/papyrus/theme.css +11 -0
  230. package/publications/pilobol_us/brand.ts +37 -0
  231. package/publications/pilobol_us/theme.css +140 -0
  232. package/publications/threat_intelligence/blog-defense/graph.ts +636 -0
  233. package/publications/threat_intelligence/blog-defense/layout.ts +934 -0
  234. package/publications/threat_intelligence/blog-defense/page-background.tsx +590 -0
  235. package/publications/threat_intelligence/brand.ts +40 -0
  236. package/publications/threat_intelligence/pictograms/art.tsx +608 -0
  237. package/publications/threat_intelligence/pictograms/figure.tsx +82 -0
  238. package/publications/threat_intelligence/pictograms/registry.ts +30 -0
  239. package/publications/threat_intelligence/pictograms/system.tsx +305 -0
  240. package/publications/threat_intelligence/seed/seed-edition-content.json +629 -0
  241. package/publications/threat_intelligence/theme.css +1440 -0
  242. package/renderers/markus/index.ts +25 -0
  243. package/renderers/markus/stubs.tsx +33 -0
  244. package/renderers/pretext/article-page.tsx +174 -0
  245. package/renderers/pretext/index.ts +30 -0
  246. package/renderers/pretext/layouts.ts +16 -0
  247. package/renderers/pretext/presentation-shell.tsx +477 -0
  248. package/routes.manifest.json +182 -0
  249. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  250. package/src/backend.js +1 -0
  251. package/src/backend.ts +3 -0
  252. package/src/sync.mjs +87 -0
  253. package/src/with-papyrus.d.mts +2 -0
  254. 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
+ }