mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 16:03:29 +00:00
Merge branch 'main' into feat/asset-graph-view
This commit is contained in:
@@ -133,12 +133,27 @@
|
||||
},
|
||||
"coupa": {
|
||||
"grant_types": ["client_credentials"],
|
||||
"cc_scopes": [
|
||||
"core.supplier.read",
|
||||
"core.supplier.write",
|
||||
"core.purchase_order.read",
|
||||
"core.purchase_order.write",
|
||||
"core.requisition.read",
|
||||
"core.requisition.write",
|
||||
"core.invoice.read",
|
||||
"core.invoice.write",
|
||||
"core.contract.read",
|
||||
"core.expense.read"
|
||||
],
|
||||
"connect_config_template": {
|
||||
"display_name": "Coupa",
|
||||
"label": "Coupa instance",
|
||||
"placeholder": "your-instance",
|
||||
"token_url": "https://{instance}.coupahost.com/oauth2/token",
|
||||
"strip_suffix": ".coupahost.com"
|
||||
"strip_suffix": ".coupahost.com",
|
||||
"resource_mapping": {
|
||||
"instance_url": "https://{instance}.coupahost.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sage_intacct": {
|
||||
|
||||
@@ -48,7 +48,9 @@ use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
|
||||
use windmill_common::PgDatabase;
|
||||
use windmill_common::{
|
||||
error::{Error, JsonResult, Result},
|
||||
global_settings::AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
global_settings::{
|
||||
AUTOMATE_USERNAME_CREATION_SETTING, DISABLE_WORKSPACE_INVITE_EMAILS_SETTING,
|
||||
},
|
||||
oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH,
|
||||
utils::{paginate, rd_string, require_admin, Pagination},
|
||||
};
|
||||
@@ -5354,6 +5356,20 @@ async fn unarchive_workspace(
|
||||
Ok(format!("Unarchived workspace {}", &w_id))
|
||||
}
|
||||
|
||||
/// Whether the instance is configured to suppress the email notifications sent
|
||||
/// when a user is invited or added to a workspace. Defaults to false (emails on).
|
||||
async fn workspace_invite_emails_disabled(db: &DB) -> Result<bool> {
|
||||
Ok(
|
||||
windmill_common::global_settings::load_value_from_global_settings(
|
||||
db,
|
||||
DISABLE_WORKSPACE_INVITE_EMAILS_SETTING,
|
||||
)
|
||||
.await?
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invite_user(
|
||||
ApiAuthed { username, is_admin, .. }: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -5412,16 +5428,18 @@ async fn invite_user(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
send_email_if_possible(
|
||||
&format!("Invited to Windmill's workspace: {w_id}"),
|
||||
&format!(
|
||||
"You have been granted access to Windmill's workspace {w_id}
|
||||
if !workspace_invite_emails_disabled(&db).await? {
|
||||
send_email_if_possible(
|
||||
&format!("Invited to Windmill's workspace: {w_id}"),
|
||||
&format!(
|
||||
"You have been granted access to Windmill's workspace {w_id}
|
||||
|
||||
If you do not have an account on {}, login with SSO or ask an admin to create an account for you.",
|
||||
(**BASE_URL.load()).clone()
|
||||
),
|
||||
&nu.email,
|
||||
);
|
||||
(**BASE_URL.load()).clone()
|
||||
),
|
||||
&nu.email,
|
||||
);
|
||||
}
|
||||
|
||||
webhook.send_instance_event(InstanceEvent::UserInvitedWorkspace {
|
||||
email: nu.email.clone(),
|
||||
@@ -5564,17 +5582,19 @@ async fn add_user(
|
||||
)
|
||||
.await?;
|
||||
|
||||
send_email_if_possible(
|
||||
&format!("Added to Windmill's workspace: {w_id}"),
|
||||
&format!(
|
||||
"You have been granted access to Windmill's workspace {w_id} by {}
|
||||
if !workspace_invite_emails_disabled(&db).await? {
|
||||
send_email_if_possible(
|
||||
&format!("Added to Windmill's workspace: {w_id}"),
|
||||
&format!(
|
||||
"You have been granted access to Windmill's workspace {w_id} by {}
|
||||
|
||||
If you do not have an account on {}, login with SSO or ask an admin to create an account for you.",
|
||||
authed.email,
|
||||
(**BASE_URL.load()).clone()
|
||||
),
|
||||
&nu.email,
|
||||
);
|
||||
authed.email,
|
||||
(**BASE_URL.load()).clone()
|
||||
),
|
||||
&nu.email,
|
||||
);
|
||||
}
|
||||
|
||||
webhook.send_instance_event(InstanceEvent::UserAddedWorkspace {
|
||||
workspace: w_id.clone(),
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Adding a global setting? Decide whether agent workers may read it. Agent
|
||||
// workers (remote workers connected over HTTP) fetch settings through an
|
||||
// endpoint that is deny-by-exception: every key is served except those in
|
||||
// AGENT_WORKER_BLOCKED_SETTINGS (defined below). If a new setting holds an
|
||||
// instance secret the server should keep to itself, add its key there.
|
||||
pub const CUSTOM_TAGS_SETTING: &str = "custom_tags";
|
||||
pub const DEFAULT_TAGS_PER_WORKSPACE_SETTING: &str = "default_tags_per_workspace";
|
||||
pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces";
|
||||
@@ -71,6 +76,7 @@ pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config";
|
||||
pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret";
|
||||
|
||||
pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation";
|
||||
pub const DISABLE_WORKSPACE_INVITE_EMAILS_SETTING: &str = "disable_workspace_invite_emails";
|
||||
pub const DISABLE_PASSWORD_LOGIN_SETTING: &str = "disable_password_login";
|
||||
pub const AUTO_LOGIN_PROVIDER_SETTING: &str = "auto_login_provider";
|
||||
pub const HUB_BASE_URL_SETTING: &str = "hub_base_url";
|
||||
@@ -104,6 +110,53 @@ pub const WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING: &str = "workspace_fairness_max
|
||||
pub const WORKSPACE_FAIRNESS_DURATION_SECS_SETTING: &str = "workspace_fairness_duration_secs";
|
||||
pub const WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING: &str = "workspace_fairness_min_total_jobs";
|
||||
|
||||
/// Global settings an agent worker (a remote worker connected over HTTP instead
|
||||
/// of to the database) must NEVER read through
|
||||
/// `GET /api/agent_workers/get_global_setting/{key}`. Every other key is served.
|
||||
///
|
||||
/// SECURITY: that endpoint is authenticated only by an agent-worker JWT and
|
||||
/// returns the raw `global_settings` value for the requested key. Because the
|
||||
/// policy is deny-by-exception (anything not listed here is readable), every
|
||||
/// setting that holds an instance secret or credential an agent worker does not
|
||||
/// need MUST be listed below. Missing one discloses it to every agent worker —
|
||||
/// `jwt_secret` is the worst case (a token holder could forge a superadmin JWT),
|
||||
/// but `oauths`, `smtp_settings`, `secret_backend`, object-store credentials,
|
||||
/// etc. are instance-wide secrets too.
|
||||
///
|
||||
/// NOT blocked, on purpose: the operational credentials an agent worker loads to
|
||||
/// run jobs (`license_key`, `hub_api_secret`, `sandbox_registry_auth`,
|
||||
/// `powershell_repo_pat`, `npmrc`, ...). Those are already within an agent
|
||||
/// worker's trust boundary, and blocking them breaks worker startup or
|
||||
/// dependency installation. When adding a new setting that stores a secret the
|
||||
/// server keeps to itself, add it here.
|
||||
pub const AGENT_WORKER_BLOCKED_SETTINGS: &[&str] = &[
|
||||
// Instance identity / auth secrets — disclosure enables privilege escalation
|
||||
// or impersonation.
|
||||
JWT_SECRET_SETTING,
|
||||
OAUTH_SETTING,
|
||||
SMTP_SETTING,
|
||||
SCIM_TOKEN_SETTING,
|
||||
SAML_METADATA_SETTING,
|
||||
SECRET_BACKEND_SETTING,
|
||||
GITHUB_ENTERPRISE_APP_SETTING,
|
||||
OBJECT_STORE_CONFIG_SETTING,
|
||||
AI_CONFIG_SETTING,
|
||||
TEAMS_SETTING,
|
||||
INDEXER_SETTING,
|
||||
// Server-only configs that may embed credentials, webhook URLs or tokens and
|
||||
// are never loaded by an agent worker.
|
||||
CRITICAL_ERROR_CHANNELS_SETTING,
|
||||
INSTANCE_EVENTS_WEBHOOK_SETTING,
|
||||
OTEL_SETTING,
|
||||
OTEL_TRACING_PROXY_SETTING,
|
||||
];
|
||||
|
||||
/// Whether an agent worker may read the given global setting over HTTP.
|
||||
/// Deny-by-exception: everything is readable except [`AGENT_WORKER_BLOCKED_SETTINGS`].
|
||||
pub fn is_setting_readable_by_agent_worker(name: &str) -> bool {
|
||||
!AGENT_WORKER_BLOCKED_SETTINGS.contains(&name)
|
||||
}
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -265,6 +318,62 @@ pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn agent_workers_can_read_operational_settings() {
|
||||
// Operational knobs and the credentials a worker needs to run jobs are
|
||||
// intentionally NOT blocked. Deny-by-exception also means an arbitrary
|
||||
// unlisted key is readable.
|
||||
for key in [
|
||||
NPMRC_SETTING,
|
||||
PIP_INDEX_URL_SETTING,
|
||||
JOB_ISOLATION_SETTING,
|
||||
LICENSE_KEY_SETTING,
|
||||
HUB_API_SECRET_SETTING,
|
||||
SANDBOX_REGISTRY_AUTH_SETTING,
|
||||
POWERSHELL_REPO_PAT_SETTING,
|
||||
"some_future_operational_setting",
|
||||
] {
|
||||
assert!(
|
||||
is_setting_readable_by_agent_worker(key),
|
||||
"'{key}' must remain readable by agent workers"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_workers_cannot_read_instance_secrets() {
|
||||
// Disclosing any of these to a remote worker enables privilege
|
||||
// escalation (jwt_secret -> forged superadmin JWT) or leaks instance
|
||||
// secrets. They must never be served by the agent-worker endpoint.
|
||||
for key in [
|
||||
JWT_SECRET_SETTING,
|
||||
OAUTH_SETTING,
|
||||
SMTP_SETTING,
|
||||
SCIM_TOKEN_SETTING,
|
||||
SAML_METADATA_SETTING,
|
||||
SECRET_BACKEND_SETTING,
|
||||
GITHUB_ENTERPRISE_APP_SETTING,
|
||||
OBJECT_STORE_CONFIG_SETTING,
|
||||
AI_CONFIG_SETTING,
|
||||
TEAMS_SETTING,
|
||||
INDEXER_SETTING,
|
||||
CRITICAL_ERROR_CHANNELS_SETTING,
|
||||
INSTANCE_EVENTS_WEBHOOK_SETTING,
|
||||
OTEL_SETTING,
|
||||
OTEL_TRACING_PROXY_SETTING,
|
||||
] {
|
||||
assert!(
|
||||
!is_setting_readable_by_agent_worker(key),
|
||||
"'{key}' is an instance secret and must not be readable by agent workers"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_value_in_global_settings(
|
||||
db: &Pool<Postgres>,
|
||||
setting_name: &str,
|
||||
|
||||
@@ -464,11 +464,13 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{:else}
|
||||
<ChatTypingIndicator
|
||||
loading={aiChatManager.loading}
|
||||
label={aiChatManager.currentReasoningActive &&
|
||||
!aiChatManager.currentReply &&
|
||||
!aiChatManager.currentReasoning
|
||||
? 'Thinking'
|
||||
: undefined}
|
||||
label={aiChatManager.compacting
|
||||
? 'Compacting conversation'
|
||||
: aiChatManager.currentReasoningActive &&
|
||||
!aiChatManager.currentReply &&
|
||||
!aiChatManager.currentReasoning
|
||||
? 'Thinking'
|
||||
: undefined}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -538,8 +540,6 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{#if inputPreface}
|
||||
{@render inputPreface()}
|
||||
{/if}
|
||||
<ContextUsageIndicator />
|
||||
|
||||
<AIChatInput
|
||||
bind:this={aiChatInput}
|
||||
bind:selectedContext
|
||||
@@ -727,6 +727,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex px-1 mt-1">
|
||||
<ContextUsageIndicator />
|
||||
</div>
|
||||
</div>
|
||||
{#if (aiChatManager.mode === AIMode.NAVIGATOR || aiChatManager.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
|
||||
<div class="px-2 mt-4">
|
||||
|
||||
@@ -36,8 +36,13 @@ import { loadApiTools } from './api/apiTools'
|
||||
import { prepareScriptUserMessage } from './script/core'
|
||||
import { prepareNavigatorUserMessage } from './navigator/core'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { workspaceAIClients } from '../lib'
|
||||
import { workspaceAIClients, getNonStreamingCompletion } from '../lib'
|
||||
import { getKnownModelContextWindow } from '../modelConfig'
|
||||
import {
|
||||
getCompactionSummaryPrompt,
|
||||
formatCompactSummary,
|
||||
buildSummaryMessageContent
|
||||
} from './compactionPrompt'
|
||||
import { dfs } from '$lib/components/flows/previousResults'
|
||||
import { getStringError } from './utils'
|
||||
import { type PasteAttachment } from './pasteTokens'
|
||||
@@ -81,15 +86,25 @@ import { isGlobalAiEnabled } from './global/gate'
|
||||
import { scopedKey, onUserChange, migrateLegacyLocalStorage } from '$lib/userScopedStorage'
|
||||
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
|
||||
// Drop-oldest compaction of the stored history: once the projected request
|
||||
// size (contextTokens — the provider's report when current, a fresh chars/4
|
||||
// estimate otherwise — plus the new user message) reaches the trigger ratio
|
||||
// of the model's context window, head messages are dropped until roughly the
|
||||
// target ratio. The trigger headroom absorbs what the projection cannot see —
|
||||
// the upcoming completion and tool results, system-prompt/tool-schema changes
|
||||
// from mode switches, and the estimate's chars/4 error.
|
||||
// Compaction of the stored history: once the projected request size
|
||||
// (contextTokens — the provider's report when current, a fresh chars/4
|
||||
// estimate otherwise — plus the new user message) reaches the trigger ratio of
|
||||
// the model's context window, the older prefix is summarized into a single
|
||||
// message while the recent tail is kept verbatim, bringing the history down to
|
||||
// roughly the target ratio. The trigger headroom absorbs what the projection
|
||||
// cannot see — the upcoming completion and tool results, system-prompt/tool-
|
||||
// schema changes from mode switches, and the estimate's chars/4 error.
|
||||
const COMPACTION_TRIGGER_RATIO = 0.8
|
||||
const COMPACTION_TARGET_RATIO = 0.7
|
||||
// Headroom reserved within the target budget for the summary message itself, so
|
||||
// the summary + kept tail + overhead land under the target ratio.
|
||||
const SUMMARY_OUTPUT_RESERVE_TOKENS = 8000
|
||||
// Below this many messages in the prefix there's little to gain from a summary
|
||||
// round-trip; skip straight to drop-oldest.
|
||||
const MIN_PREFIX_MESSAGES_TO_SUMMARIZE = 4
|
||||
// Stop attempting summarization after this many consecutive failures and use
|
||||
// drop-oldest directly; a successful summarization resets the counter.
|
||||
const MAX_CONSECUTIVE_COMPACTION_FAILURES = 3
|
||||
// Abort reason for a deliberate user cancel (Esc / Stop). Programmatic cancels
|
||||
// (panel teardown, save-and-clear) pass their own reason, so the queued-message
|
||||
// flush can tell "the user wants to move on" from "the turn was torn down".
|
||||
@@ -242,6 +257,13 @@ export class AIChatManager {
|
||||
* (provider never reported, turn failed, history rewound). Never holds a
|
||||
* guess: readers go through `contextTokens`, which estimates lazily. */
|
||||
contextUsage = $state<number | undefined>(undefined)
|
||||
// Circuit breaker for summary-based compaction: after repeated failures the
|
||||
// summary round-trip is skipped in favor of drop-oldest. Reset on any
|
||||
// successful summarization. Not persisted — a fresh load gets a fresh chance.
|
||||
private consecutiveCompactionFailures = 0
|
||||
// True while the summarization round-trip is in flight, so the UI can show a
|
||||
// "Compacting conversation" label on the processing indicator.
|
||||
compacting = $state(false)
|
||||
autonomyMode = $state<AIAutonomyMode>(getPersistedAutonomyMode())
|
||||
autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode))
|
||||
autoAcceptEditsActive = $derived(
|
||||
@@ -397,6 +419,122 @@ export class AIChatManager {
|
||||
return freed
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary-based partial compaction. Summarizes the older PREFIX of the stored
|
||||
* history into a single user message and keeps the recent tail verbatim,
|
||||
* bringing the history down to roughly the target ratio while preserving the
|
||||
* intent, decisions, and recent work that drop-oldest would discard.
|
||||
*
|
||||
* The tail grows from the most recent message until it fills `tailBudget`,
|
||||
* then snaps forward to a user-message boundary (a leading tool/assistant
|
||||
* message would dangle without the turn that introduced it). The summary
|
||||
* replaces the prefix in BOTH `messages` (as a user message) and
|
||||
* `displayMessages` (as a `summary` boundary); surviving tail user messages
|
||||
* have their restart `index` re-based onto the new history.
|
||||
*
|
||||
* Returns true on success. Returns false — caller falls back to drop-oldest —
|
||||
* when summarization isn't worthwhile or fails (user abort, empty summary, or
|
||||
* the circuit breaker being tripped).
|
||||
*/
|
||||
private summarizeAndCompact = async (contextWindow: number): Promise<boolean> => {
|
||||
if (this.consecutiveCompactionFailures >= MAX_CONSECUTIVE_COMPACTION_FAILURES) {
|
||||
return false
|
||||
}
|
||||
const abortController = this.abortController
|
||||
if (!abortController) {
|
||||
return false
|
||||
}
|
||||
|
||||
const tailBudget =
|
||||
contextWindow * COMPACTION_TARGET_RATIO -
|
||||
SUMMARY_OUTPUT_RESERVE_TOKENS -
|
||||
this.estimateOverheadTokens()
|
||||
if (tailBudget <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const last = this.messages.length - 1
|
||||
if (last < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Grow the tail from the most recent message downward while it fits the
|
||||
// budget; always keep at least the last message.
|
||||
let keepFrom = last
|
||||
let tailTokens = 0
|
||||
for (let i = last; i >= 1; i--) {
|
||||
const t = this.estimateMessagesTokens([this.messages[i]])
|
||||
if (i < last && tailTokens + t > tailBudget) {
|
||||
break
|
||||
}
|
||||
tailTokens += t
|
||||
keepFrom = i
|
||||
}
|
||||
// The tail must start on a user message — move the boundary forward over
|
||||
// any leading tool/assistant messages, folding them into the prefix.
|
||||
while (keepFrom < last && this.messages[keepFrom].role !== 'user') {
|
||||
keepFrom++
|
||||
}
|
||||
|
||||
const prefix = this.messages.slice(0, keepFrom)
|
||||
const tail = this.messages.slice(keepFrom)
|
||||
if (prefix.length < MIN_PREFIX_MESSAGES_TO_SUMMARIZE || tail.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// The user message at the boundary has a display counterpart with the same
|
||||
// index; resolve it before any mutation so a corrupt transcript can never
|
||||
// result from an unexpected miss.
|
||||
const displayKeepFrom = this.displayMessages.findIndex(
|
||||
(m) => m.role === 'user' && m.index >= keepFrom
|
||||
)
|
||||
if (displayKeepFrom === -1) {
|
||||
this.consecutiveCompactionFailures++
|
||||
return false
|
||||
}
|
||||
|
||||
this.compacting = true
|
||||
try {
|
||||
const raw = await getNonStreamingCompletion(
|
||||
[...prefix, { role: 'user', content: getCompactionSummaryPrompt() }],
|
||||
abortController
|
||||
)
|
||||
const formatted = formatCompactSummary(raw ?? '')
|
||||
if (!formatted) {
|
||||
this.consecutiveCompactionFailures++
|
||||
return false
|
||||
}
|
||||
|
||||
this.messages = [{ role: 'user', content: buildSummaryMessageContent(formatted) }, ...tail]
|
||||
|
||||
// Replace the summarized display prefix with the boundary marker and
|
||||
// re-base the surviving tail's restart indices (the summary occupies
|
||||
// slot 0, so the tail now starts at slot 1).
|
||||
this.displayMessages = [
|
||||
{ role: 'summary', content: formatted },
|
||||
...this.displayMessages
|
||||
.slice(displayKeepFrom)
|
||||
.map((m) => (m.role === 'user' ? { ...m, index: m.index - keepFrom + 1 } : m))
|
||||
]
|
||||
|
||||
// The provider report described the pre-compaction history; the new
|
||||
// history is much smaller, so clear it and let readers re-estimate.
|
||||
this.contextUsage = undefined
|
||||
this.consecutiveCompactionFailures = 0
|
||||
return true
|
||||
} catch (err) {
|
||||
// A user Stop aborts the in-flight summary — that's a turn cancel, not a
|
||||
// compaction failure, so it doesn't count toward the circuit breaker.
|
||||
if (!abortController.signal.aborted) {
|
||||
console.error('Conversation summarization failed', err)
|
||||
this.consecutiveCompactionFailures++
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
this.compacting = false
|
||||
}
|
||||
}
|
||||
|
||||
loadApiTools = async () => {
|
||||
try {
|
||||
this.apiTools = await loadApiTools()
|
||||
@@ -1156,7 +1294,6 @@ export class AIChatManager {
|
||||
// not the expanded LLM text, plus the rollback anchor after the user turn.
|
||||
const sentInstructions = this.instructions
|
||||
const sentPastes = pastes
|
||||
const displayLenAfterUser = this.displayMessages.length
|
||||
// The LLM gets the full pasted content; the display message above keeps
|
||||
// the compact tokens + registry so the bubble can render/expand chips.
|
||||
const oldInstructions = expanded(chatDraft(this.instructions, pastes))
|
||||
@@ -1226,22 +1363,38 @@ export class AIChatManager {
|
||||
contextWindow !== undefined &&
|
||||
projectedContextTokens >= contextWindow * COMPACTION_TRIGGER_RATIO
|
||||
) {
|
||||
const freed = this.compactOldestMessages(
|
||||
projectedContextTokens - contextWindow * COMPACTION_TARGET_RATIO
|
||||
)
|
||||
// A report stays meaningful only debited by what was dropped; the
|
||||
// estimate path needs no bookkeeping — the next read re-estimates
|
||||
// the compacted history. chars/4 can underestimate the freed
|
||||
// tokens, which errs toward compacting again — never toward
|
||||
// overflowing.
|
||||
if (this.contextUsage !== undefined) {
|
||||
this.contextUsage = Math.max(0, this.contextUsage - freed)
|
||||
// Preferred path: summarize the older prefix, keep the recent tail.
|
||||
const summarized = await this.summarizeAndCompact(contextWindow)
|
||||
// A Stop during the in-flight summary aborts this turn's controller;
|
||||
// summarizeAndCompact then returns false without touching history. Skip
|
||||
// the drop-oldest fallback (and its save) — it would destructively
|
||||
// compact a conversation the user only meant to cancel, and the request
|
||||
// can't run on an aborted controller anyway. The cancel path below rolls
|
||||
// the pushed turn back cleanly on its own.
|
||||
if (!this.abortController?.signal.aborted) {
|
||||
if (!summarized) {
|
||||
// Fallback when summarization isn't worthwhile or fails: drop the
|
||||
// oldest messages. A report stays meaningful only debited by what
|
||||
// was dropped; the estimate path needs no bookkeeping — the next
|
||||
// read re-estimates the compacted history. chars/4 can
|
||||
// underestimate the freed tokens, which errs toward compacting
|
||||
// again — never toward overflowing.
|
||||
const freed = this.compactOldestMessages(
|
||||
projectedContextTokens - contextWindow * COMPACTION_TARGET_RATIO
|
||||
)
|
||||
if (this.contextUsage !== undefined) {
|
||||
this.contextUsage = Math.max(0, this.contextUsage - freed)
|
||||
}
|
||||
}
|
||||
await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage)
|
||||
}
|
||||
await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage)
|
||||
}
|
||||
// Rollback anchor for restoreUnsentTurn: captured after compaction so it
|
||||
// indexes into the (possibly compacted) stored history.
|
||||
// Rollback anchors for restoreUnsentTurn: captured after compaction so
|
||||
// they index into the (possibly compacted) stored history. The summary
|
||||
// path shrinks displayMessages too, so the display anchor must also be
|
||||
// read here, not before compaction.
|
||||
const modelLenAfterUser = this.messages.length
|
||||
const displayLenAfterUser = this.displayMessages.length
|
||||
|
||||
const params: {
|
||||
messages: ChatCompletionMessageParam[]
|
||||
|
||||
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
|
||||
sendUserToast: vi.fn(),
|
||||
getOpenaiClient: vi.fn(),
|
||||
getAnthropicClient: vi.fn(),
|
||||
getNonStreamingCompletion: vi.fn(),
|
||||
runChatLoop: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -60,7 +61,8 @@ vi.mock('../lib', () => ({
|
||||
subscribe: () => () => undefined,
|
||||
getOpenaiClient: mocks.getOpenaiClient,
|
||||
getAnthropicClient: mocks.getAnthropicClient
|
||||
}
|
||||
},
|
||||
getNonStreamingCompletion: mocks.getNonStreamingCompletion
|
||||
}))
|
||||
|
||||
vi.mock('./api/apiTools', () => ({
|
||||
@@ -770,6 +772,146 @@ describe('AIChatManager context compaction', () => {
|
||||
await manager.saveAndClear()
|
||||
expect(manager.contextUsage).toBeUndefined()
|
||||
})
|
||||
|
||||
// gpt-4o resolves to a known 128k window (modelConfig unmocked): trigger at
|
||||
// ~102k, target ~90k. With a summary reserve of 8k the tail budget is ~76k.
|
||||
const gpt4oModel = { provider: 'openai', model: 'gpt-4o' }
|
||||
|
||||
// Older prefix (4 messages, ~25k tokens each = 100k chars) plus a recent
|
||||
// user+assistant pair that fits the tail budget. After the new user turn is
|
||||
// pushed the budget keeps [recentQ, recentA, new] verbatim and summarizes the
|
||||
// four old messages.
|
||||
function seedForSummary(manager: AIChatManager) {
|
||||
manager.messages = [
|
||||
{ role: 'user', content: 'OLD1' + 'a'.repeat(100_000) },
|
||||
{ role: 'assistant', content: 'OLD2' + 'b'.repeat(100_000) },
|
||||
{ role: 'user', content: 'OLD3' + 'c'.repeat(100_000) },
|
||||
{ role: 'assistant', content: 'OLD4' + 'd'.repeat(100_000) },
|
||||
{ role: 'user', content: 'recentQ' + 'e'.repeat(80_000) },
|
||||
{ role: 'assistant', content: 'recentA' + 'f'.repeat(80_000) }
|
||||
]
|
||||
manager.displayMessages = [
|
||||
{ role: 'user', content: 'old1', index: 0 },
|
||||
{ role: 'assistant', content: 'old2' },
|
||||
{ role: 'user', content: 'old3', index: 2 },
|
||||
{ role: 'assistant', content: 'old4' },
|
||||
{ role: 'user', content: 'recentQ', index: 4 },
|
||||
{ role: 'assistant', content: 'recentA' }
|
||||
]
|
||||
manager.instructions = 'next question'
|
||||
}
|
||||
|
||||
it('summarizes the older prefix and keeps the recent tail verbatim', async () => {
|
||||
mocks.getCurrentModel.mockReturnValue(gpt4oModel)
|
||||
mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel)
|
||||
mocks.getNonStreamingCompletion.mockResolvedValue(
|
||||
'<analysis>scratchpad</analysis><summary>SUMMARY TEXT</summary>'
|
||||
)
|
||||
const manager = new AIChatManager()
|
||||
seedForSummary(manager)
|
||||
|
||||
await manager.sendRequest()
|
||||
|
||||
// The prefix (the four OLD messages) was sent to the summarizer, followed
|
||||
// by the summary-instruction user message.
|
||||
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
|
||||
const summaryReq = mocks.getNonStreamingCompletion.mock.calls[0][0]
|
||||
expect(summaryReq).toHaveLength(5)
|
||||
expect(summaryReq[0].content).toContain('OLD1')
|
||||
expect(summaryReq[3].content).toContain('OLD4')
|
||||
expect(summaryReq[4].content).toContain('detailed summary')
|
||||
|
||||
// The request that went out begins with the summary user message, then the
|
||||
// recent tail verbatim, then the new question.
|
||||
const sent = mocks.runChatLoop.mock.calls[0][0].messages
|
||||
expect(sent).toHaveLength(4)
|
||||
expect(sent[0].role).toBe('user')
|
||||
expect(sent[0].content).toContain('SUMMARY TEXT')
|
||||
expect(sent[0].content).toContain('continued from a previous conversation')
|
||||
expect(sent[0].content).not.toContain('scratchpad')
|
||||
expect(sent[1].content).toContain('recentQ')
|
||||
|
||||
// The display transcript replaces the summarized bubbles with one boundary
|
||||
// and re-bases the surviving tail's restart indices onto the new history.
|
||||
expect(manager.displayMessages[0]).toMatchObject({ role: 'summary', content: 'SUMMARY TEXT' })
|
||||
const recentQDisplay = manager.displayMessages.find(
|
||||
(m) => m.role === 'user' && m.content === 'recentQ'
|
||||
)
|
||||
expect(recentQDisplay && 'index' in recentQDisplay ? recentQDisplay.index : undefined).toBe(1)
|
||||
// No report describes the new history, so the readable number re-estimates
|
||||
// the now-small compacted context.
|
||||
expect(manager.contextUsage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to drop-oldest when summarization fails', async () => {
|
||||
mocks.getCurrentModel.mockReturnValue(gpt4oModel)
|
||||
mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel)
|
||||
mocks.getNonStreamingCompletion.mockRejectedValue(new Error('summary boom'))
|
||||
const manager = new AIChatManager()
|
||||
seedForSummary(manager)
|
||||
|
||||
await manager.sendRequest()
|
||||
|
||||
// Summarization was attempted, then the request still went out — via
|
||||
// drop-oldest, so no summary boundary anywhere.
|
||||
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.runChatLoop).toHaveBeenCalledTimes(1)
|
||||
const sent = mocks.runChatLoop.mock.calls[0][0].messages
|
||||
expect(sent[0].content).not.toContain('continued from a previous conversation')
|
||||
expect(manager.displayMessages.some((m) => m.role === 'summary')).toBe(false)
|
||||
})
|
||||
|
||||
it('skips summarization (drop-oldest) when the prefix is too small', async () => {
|
||||
mocks.getCurrentModel.mockReturnValue(anthropicModel)
|
||||
mocks.tryGetCurrentModel.mockReturnValue(anthropicModel)
|
||||
const manager = new AIChatManager()
|
||||
manager.messages = [
|
||||
{ role: 'user', content: 'a'.repeat(400_000) },
|
||||
{ role: 'assistant', content: 'b'.repeat(400_000) },
|
||||
{ role: 'user', content: 'c'.repeat(400) }
|
||||
]
|
||||
manager.contextUsage = 850_000
|
||||
manager.instructions = 'next question'
|
||||
|
||||
await manager.sendRequest()
|
||||
|
||||
// A two-message prefix isn't worth a summary round-trip.
|
||||
expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled()
|
||||
expect(manager.displayMessages.some((m) => m.role === 'summary')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not drop-oldest compact when the user stops during summarization', async () => {
|
||||
mocks.getCurrentModel.mockReturnValue(gpt4oModel)
|
||||
mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel)
|
||||
// The user hits Stop while the summary request is in flight: it aborts the
|
||||
// turn's controller and rejects.
|
||||
mocks.getNonStreamingCompletion.mockImplementation(
|
||||
async (_msgs: any, ac: AbortController) => {
|
||||
ac.abort('user_cancelled')
|
||||
throw new Error('aborted')
|
||||
}
|
||||
)
|
||||
// With the controller already aborted, the real request returns nothing;
|
||||
// mirror that so the turn takes the cancel/rollback path.
|
||||
mocks.runChatLoop.mockImplementation(async () => ({
|
||||
addedMessages: [],
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
lastIterationUsage: null,
|
||||
hitMaxIterations: false
|
||||
}))
|
||||
const manager = new AIChatManager()
|
||||
seedForSummary(manager)
|
||||
|
||||
await manager.sendRequest()
|
||||
|
||||
// Summarization was attempted and aborted, but the abort must NOT trigger a
|
||||
// destructive drop-oldest fallback: the full prefix survives and the unsent
|
||||
// turn is rolled back to the pre-send history (the head pair is still there).
|
||||
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(manager.messages).toHaveLength(6)
|
||||
expect(manager.messages[0].content).toContain('OLD1')
|
||||
expect(manager.displayMessages.some((m) => m.role === 'summary')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const assistantToolCall = (id: string): ChatCompletionMessageParam => ({
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import AIChatInput from './AIChatInput.svelte'
|
||||
import type { ContextElement } from './context'
|
||||
import ToolExecutionDisplay from './ToolExecutionDisplay.svelte'
|
||||
import CompactionBoundary from './CompactionBoundary.svelte'
|
||||
import { messageDraft, segments } from './chatDraft'
|
||||
import { lineCountLabel } from './pasteTokens'
|
||||
|
||||
@@ -50,103 +51,111 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={twMerge(
|
||||
'mb-2 min-w-0',
|
||||
message.role === 'tool' && 'mb-1',
|
||||
message.role === 'user' && messageIndex > 0 && 'mt-4 mb-6',
|
||||
isLast && '!mb-12',
|
||||
message.role !== 'user' ? 'cursor-default' : 'cursor-pointer'
|
||||
)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => editMessage()}
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
{#if message.role === 'user' && message.contextElements && editingMessageIndex !== messageIndex}
|
||||
<div class="flex flex-row gap-1 mb-1 overflow-scroll no-scrollbar px-2">
|
||||
{#each message.contextElements as element}
|
||||
<ContextElementBadge contextElement={element} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if message.role === 'user' && editingMessageIndex === messageIndex}
|
||||
<div class="px-2 max-w-lg">
|
||||
<AIChatInput
|
||||
{availableContext}
|
||||
bind:selectedContext
|
||||
initialInstructions={message.content}
|
||||
initialPastes={message.pastes}
|
||||
{editingMessageIndex}
|
||||
onClickOutside={() => (editingMessageIndex = null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
editingMessageIndex = null
|
||||
}
|
||||
}}
|
||||
onEditEnd={() => (editingMessageIndex = null)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class={twMerge('text-sm py-1 px-2', message.role === 'tool' && 'text-primary py-0')}>
|
||||
{#if message.role === 'assistant'}
|
||||
<div class="px-[1px]"><AssistantMessage {message} /></div>
|
||||
{:else if message.role === 'tool'}
|
||||
<div class="px-[1px]"><ToolExecutionDisplay message={message as ToolDisplayMessage} /></div>
|
||||
{:else}
|
||||
<div
|
||||
class="text-xs px-3 py-2 w-fit max-w-[min(32rem,100%)] bg-surface-accent-selected text-accent rounded-lg relative group break-words"
|
||||
>
|
||||
{#each segments(messageDraft(message)) as seg}{#if seg.type === 'text'}<span
|
||||
class="whitespace-pre-wrap">{seg.value}</span
|
||||
>{:else if expandedPastes.has(seg.att.id)}<button
|
||||
type="button"
|
||||
class="my-0.5 px-1.5 py-0.5 rounded bg-surface-secondary text-secondary text-2xs"
|
||||
onclick={(e) => togglePaste(e, seg.att.id)}
|
||||
>{lineCountLabel(seg.att.lines)} · click to collapse</button
|
||||
><span class="block whitespace-pre-wrap mt-1">{seg.att.content}</span>{:else}<button
|
||||
type="button"
|
||||
class="px-1.5 py-0.5 rounded bg-surface-secondary text-secondary text-2xs"
|
||||
onclick={(e) => togglePaste(e, seg.att.id)}
|
||||
>Pasted {lineCountLabel(seg.att.lines)} · click to expand</button
|
||||
>{/if}{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if message.role === 'user' && message.snapshot}
|
||||
<div class="mx-2 text-2xs text-tertiary flex flex-row items-center justify-between gap-2 mt-2">
|
||||
Saved {message.snapshot.type === 'flow' ? 'a flow' : 'an app'} snapshot
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
on:click={() => {
|
||||
if (message.snapshot) {
|
||||
if (message.snapshot.type === 'flow') {
|
||||
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
|
||||
} else if (message.snapshot.type === 'app') {
|
||||
aiChatManager.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
|
||||
{#if message.role === 'summary'}
|
||||
<CompactionBoundary content={message.content} />
|
||||
{:else}
|
||||
<div
|
||||
class={twMerge(
|
||||
'mb-2 min-w-0',
|
||||
message.role === 'tool' && 'mb-1',
|
||||
message.role === 'user' && messageIndex > 0 && 'mt-4 mb-6',
|
||||
isLast && '!mb-12',
|
||||
message.role !== 'user' ? 'cursor-default' : 'cursor-pointer'
|
||||
)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => editMessage()}
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
{#if message.role === 'user' && message.contextElements && editingMessageIndex !== messageIndex}
|
||||
<div class="flex flex-row gap-1 mb-1 overflow-scroll no-scrollbar px-2">
|
||||
{#each message.contextElements as element}
|
||||
<ContextElementBadge contextElement={element} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if message.role === 'user' && editingMessageIndex === messageIndex}
|
||||
<div class="px-2 max-w-lg">
|
||||
<AIChatInput
|
||||
{availableContext}
|
||||
bind:selectedContext
|
||||
initialInstructions={message.content}
|
||||
initialPastes={message.pastes}
|
||||
{editingMessageIndex}
|
||||
onClickOutside={() => (editingMessageIndex = null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
editingMessageIndex = null
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Revert to snapshot"
|
||||
startIcon={{ icon: Undo2Icon }}
|
||||
}}
|
||||
onEditEnd={() => (editingMessageIndex = null)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class={twMerge('text-sm py-1 px-2', message.role === 'tool' && 'text-primary py-0')}>
|
||||
{#if message.role === 'assistant'}
|
||||
<div class="px-[1px]"><AssistantMessage {message} /></div>
|
||||
{:else if message.role === 'tool'}
|
||||
<div class="px-[1px]"
|
||||
><ToolExecutionDisplay message={message as ToolDisplayMessage} /></div
|
||||
>
|
||||
{:else}
|
||||
<div
|
||||
class="text-xs px-3 py-2 w-fit max-w-[min(32rem,100%)] bg-surface-accent-selected text-accent rounded-lg relative group break-words"
|
||||
>
|
||||
{#each segments(messageDraft(message)) as seg}{#if seg.type === 'text'}<span
|
||||
class="whitespace-pre-wrap">{seg.value}</span
|
||||
>{:else if expandedPastes.has(seg.att.id)}<button
|
||||
type="button"
|
||||
class="my-0.5 px-1.5 py-0.5 rounded bg-surface-secondary text-secondary text-2xs"
|
||||
onclick={(e) => togglePaste(e, seg.att.id)}
|
||||
>{lineCountLabel(seg.att.lines)} · click to collapse</button
|
||||
><span class="block whitespace-pre-wrap mt-1">{seg.att.content}</span>{:else}<button
|
||||
type="button"
|
||||
class="px-1.5 py-0.5 rounded bg-surface-secondary text-secondary text-2xs"
|
||||
onclick={(e) => togglePaste(e, seg.att.id)}
|
||||
>Pasted {lineCountLabel(seg.att.lines)} · click to expand</button
|
||||
>{/if}{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if message.role === 'user' && message.snapshot}
|
||||
<div
|
||||
class="mx-2 text-2xs text-tertiary flex flex-row items-center justify-between gap-2 mt-2"
|
||||
>
|
||||
Revert
|
||||
Saved {message.snapshot.type === 'flow' ? 'a flow' : 'an app'} snapshot
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
on:click={() => {
|
||||
if (message.snapshot) {
|
||||
if (message.snapshot.type === 'flow') {
|
||||
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
|
||||
} else if (message.snapshot.type === 'app') {
|
||||
aiChatManager.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Revert to snapshot"
|
||||
startIcon={{ icon: Undo2Icon }}
|
||||
>
|
||||
Revert
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if message.role === 'user' && message.error}
|
||||
<div class="flex justify-end px-2 -mt-1">
|
||||
<Button
|
||||
size="xs2"
|
||||
variant="default"
|
||||
title="Retry generation"
|
||||
startIcon={{ icon: RefreshCwIcon }}
|
||||
onclick={() => aiChatManager.retryRequest(messageIndex)}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if message.role === 'user' && message.error}
|
||||
<div class="flex justify-end px-2 -mt-1">
|
||||
<Button
|
||||
size="xs2"
|
||||
variant="default"
|
||||
title="Retry generation"
|
||||
startIcon={{ icon: RefreshCwIcon }}
|
||||
onclick={() => aiChatManager.retryRequest(messageIndex)}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { ChevronDown, ChevronRight, History } from 'lucide-svelte'
|
||||
|
||||
let { content }: { content: string } = $props()
|
||||
|
||||
let expanded = $state(false)
|
||||
</script>
|
||||
|
||||
<div class="my-4 px-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-px flex-1 bg-surface-selected"></div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs2"
|
||||
startIcon={{ icon: expanded ? ChevronDown : ChevronRight }}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 text-2xs text-tertiary">
|
||||
<History size={12} />
|
||||
Summarized earlier conversation
|
||||
</span>
|
||||
</Button>
|
||||
<div class="h-px flex-1 bg-surface-selected"></div>
|
||||
</div>
|
||||
{#if expanded}
|
||||
<div
|
||||
class="mt-2 max-h-80 overflow-y-auto whitespace-pre-wrap rounded-md bg-surface-secondary p-3 text-xs text-secondary"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -428,21 +428,25 @@ export default class ContextManager {
|
||||
displayMessages: DisplayMessage[],
|
||||
dbSchemas: DBSchemas
|
||||
): DisplayMessage[] {
|
||||
return displayMessages.map((m) => ({
|
||||
...m,
|
||||
contextElements:
|
||||
m.role !== 'tool' && m.contextElements
|
||||
? m.contextElements.map((c) =>
|
||||
c.type === 'db'
|
||||
? {
|
||||
type: 'db',
|
||||
title: c.title,
|
||||
schema: dbSchemas[c.title]
|
||||
}
|
||||
: c
|
||||
)
|
||||
: undefined
|
||||
}))
|
||||
return displayMessages.map((m) => {
|
||||
// Only user/assistant messages carry contextElements; tool and summary
|
||||
// messages pass through untouched.
|
||||
if ((m.role === 'user' || m.role === 'assistant') && m.contextElements) {
|
||||
return {
|
||||
...m,
|
||||
contextElements: m.contextElements.map((c) =>
|
||||
c.type === 'db'
|
||||
? {
|
||||
type: 'db' as const,
|
||||
title: c.title,
|
||||
schema: dbSchemas[c.title]
|
||||
}
|
||||
: c
|
||||
)
|
||||
}
|
||||
}
|
||||
return m
|
||||
})
|
||||
}
|
||||
|
||||
setSelectedModuleContext(
|
||||
|
||||
@@ -15,13 +15,9 @@
|
||||
// one describes the current history (one turn stale by nature), otherwise
|
||||
// a live chars/4 estimate of the stored context.
|
||||
let usedTokens = $derived(Math.round(aiChatManager.contextTokens))
|
||||
// With a known window, only surface once the conversation actually fills it;
|
||||
// without one there is no threshold to compare against, so always show.
|
||||
let visible = $derived(
|
||||
usedTokens > 0 &&
|
||||
aiChatManager.messages.length > 0 &&
|
||||
(contextWindow === undefined || usedTokens >= contextWindow * 0.5)
|
||||
)
|
||||
// Always surface usage once a conversation has started, at any fill level, so
|
||||
// the user can watch context grow toward the compaction threshold.
|
||||
let visible = $derived(usedTokens > 0 && aiChatManager.messages.length > 0)
|
||||
|
||||
function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1_000_000) {
|
||||
@@ -35,11 +31,9 @@
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="flex justify-end px-1">
|
||||
<span class="text-[0.6rem] text-tertiary tabular-nums" aria-label="Context window usage">
|
||||
context window usage: ~{formatTokenCount(usedTokens)}{contextWindow
|
||||
? ` / ${formatTokenCount(contextWindow)}`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[0.6rem] text-tertiary tabular-nums" aria-label="Context window usage">
|
||||
Context usage: ~{formatTokenCount(usedTokens)}{contextWindow
|
||||
? ` / ${formatTokenCount(contextWindow)}`
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -179,9 +179,19 @@ export default class HistoryManager {
|
||||
contextUsage?: number
|
||||
) {
|
||||
if (displayMessages.length > 0) {
|
||||
// Expand any collapsed-paste tokens so the title is readable text, not
|
||||
// the chip label + its zero-width id chars.
|
||||
const title = expanded(messageDraft(displayMessages[0])).slice(0, 50)
|
||||
// Compaction replaces the original first message with a summary boundary.
|
||||
// Re-deriving the title would then shift it to the first surviving tail
|
||||
// message, so once that boundary leads the transcript, keep the title
|
||||
// computed before compaction. Otherwise derive it from the first message,
|
||||
// expanding collapsed-paste tokens so it reads as text rather than the
|
||||
// chip label + its zero-width id chars.
|
||||
const existingTitle = this.savedChats[this.currentChatId]?.title
|
||||
const title =
|
||||
displayMessages[0].role === 'summary' && existingTitle !== undefined
|
||||
? existingTitle
|
||||
: expanded(
|
||||
messageDraft(displayMessages.find((m) => m.role !== 'summary') ?? displayMessages[0])
|
||||
).slice(0, 50)
|
||||
// we don't want to save the snapshot in the history
|
||||
const updatedChat = {
|
||||
actualMessages: $state.snapshot(messages),
|
||||
|
||||
@@ -114,3 +114,30 @@ describe('HistoryManager legacy chat-history migration', () => {
|
||||
expect(await countChats('copilot-chat-history::admin@test')).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HistoryManager title across compaction', () => {
|
||||
it('keeps the original title once a summary boundary leads the transcript', async () => {
|
||||
const hm = new HistoryManager()
|
||||
await hm.init()
|
||||
const id = hm.getCurrentChatId()
|
||||
|
||||
// First save derives the title from the first user message.
|
||||
await hm.save(
|
||||
[{ role: 'user', content: 'original first question', index: 0 }] as DisplayMessage[],
|
||||
[] as ChatCompletionMessageParam[]
|
||||
)
|
||||
expect(hm.getAllSavedChats().find((c) => c.id === id)?.title).toBe('original first question')
|
||||
|
||||
// After compaction the transcript leads with a summary boundary; deriving
|
||||
// the title now would shift it to the surviving tail message. It must stay
|
||||
// the title computed before compaction.
|
||||
await hm.save(
|
||||
[
|
||||
{ role: 'summary', content: 'summary of the earlier conversation' },
|
||||
{ role: 'user', content: 'a much later follow-up', index: 1 }
|
||||
] as DisplayMessage[],
|
||||
[] as ChatCompletionMessageParam[]
|
||||
)
|
||||
expect(hm.getAllSavedChats().find((c) => c.id === id)?.title).toBe('original first question')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildSummaryMessageContent,
|
||||
formatCompactSummary,
|
||||
getCompactionSummaryPrompt
|
||||
} from './compactionPrompt'
|
||||
|
||||
describe('formatCompactSummary', () => {
|
||||
it('strips the analysis scratchpad and unwraps the summary block', () => {
|
||||
const raw = `<analysis>
|
||||
chronological thinking the model should not keep
|
||||
</analysis>
|
||||
<summary>
|
||||
1. Primary Request and Intent: build the thing
|
||||
2. Pending Tasks: none
|
||||
</summary>`
|
||||
const formatted = formatCompactSummary(raw)
|
||||
expect(formatted).not.toContain('chronological thinking')
|
||||
expect(formatted).not.toContain('<analysis>')
|
||||
expect(formatted).not.toContain('<summary>')
|
||||
expect(formatted).toContain('Primary Request and Intent: build the thing')
|
||||
})
|
||||
|
||||
it('falls back to the trimmed raw text when the model omits the tags', () => {
|
||||
expect(formatCompactSummary(' just a plain summary ')).toBe('just a plain summary')
|
||||
})
|
||||
|
||||
it('keeps the summary even when there is no analysis block', () => {
|
||||
expect(formatCompactSummary('<summary>only the summary</summary>')).toBe('only the summary')
|
||||
})
|
||||
|
||||
it('collapses the blank-line runs left by stripping analysis', () => {
|
||||
const raw = '<analysis>x</analysis>\n\n\n\n<summary>a\n\n\n\nb</summary>'
|
||||
expect(formatCompactSummary(raw)).toBe('a\n\nb')
|
||||
})
|
||||
|
||||
it('strips every analysis block, not just the first, when the summary is untagged', () => {
|
||||
const raw = '<analysis>first</analysis>\nkept one\n<analysis>second</analysis>\nkept two'
|
||||
const formatted = formatCompactSummary(raw)
|
||||
expect(formatted).not.toContain('first')
|
||||
expect(formatted).not.toContain('second')
|
||||
expect(formatted).not.toContain('<analysis>')
|
||||
expect(formatted).toContain('kept one')
|
||||
expect(formatted).toContain('kept two')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSummaryMessageContent', () => {
|
||||
it('embeds the summary and frames it as a continuation', () => {
|
||||
const content = buildSummaryMessageContent('THE SUMMARY')
|
||||
expect(content).toContain('THE SUMMARY')
|
||||
expect(content).toContain('continued from a previous conversation')
|
||||
expect(content).toContain('preserved verbatim')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCompactionSummaryPrompt', () => {
|
||||
it('asks for a structured, text-only summary', () => {
|
||||
const prompt = getCompactionSummaryPrompt()
|
||||
expect(prompt).toContain('detailed summary')
|
||||
expect(prompt).toContain('<summary>')
|
||||
expect(prompt).toContain('TEXT ONLY')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
// Summary-based compaction: when a conversation approaches the model's context
|
||||
// window, the older prefix is replaced by an LLM-generated structured summary
|
||||
// while the recent tail is kept verbatim. The summary precedes the kept tail,
|
||||
// so it is written "up to" the point of compaction — newer messages the model
|
||||
// does not see here will follow it.
|
||||
//
|
||||
// Prompt structure and the analysis/summary split are adapted from the
|
||||
// reference compaction prompt used by coding agents.
|
||||
|
||||
// Reinforce text-only output. The summary call is issued without tools, but a
|
||||
// strong instruction keeps weaker models from narrating a tool call instead of
|
||||
// producing the summary.
|
||||
const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
|
||||
|
||||
- You already have all the context you need in the conversation above.
|
||||
- Your entire response must be plain text: an <analysis> block followed by a <summary> block.
|
||||
|
||||
`
|
||||
|
||||
const NO_TOOLS_TRAILER =
|
||||
'\n\nREMINDER: Respond with plain text only — an <analysis> block followed by a <summary> block.'
|
||||
|
||||
// The <analysis> block is a drafting scratchpad that formatCompactSummary()
|
||||
// strips before the summary reaches context.
|
||||
const SUMMARY_PROMPT = `Your task is to create a detailed summary of the conversation so far. This summary will be placed at the start of a continuing session; newer messages that build on this context will follow after it (you do not see them here). Summarize thoroughly so that someone reading only your summary and then the newer messages can fully understand what happened and continue the work without losing context.
|
||||
|
||||
This is a conversation with Windmill's global workspace assistant. It inspects workspace items and authors them as per-user drafts — scripts, flows, apps, resources, variables, triggers, and schedules — then deploys those drafts and test-runs scripts and flows. It works with items by their workspace path (e.g. \`u/alice/sync_orders\`, \`f/team/my_flow\`); it does NOT edit files on a filesystem. Frame the summary in those terms.
|
||||
|
||||
Before providing your final summary, wrap your analysis in <analysis> tags to organize your thoughts. In your analysis:
|
||||
|
||||
1. Chronologically analyze each message and section of the conversation. For each section thoroughly identify:
|
||||
- The user's explicit requests and intents
|
||||
- Your approach to addressing the user's requests
|
||||
- Key decisions, technical concepts and code patterns
|
||||
- Specific details: workspace item paths and kinds (script / flow / app / resource / variable / trigger / schedule), code snippets for runnables, and the exact actions taken (drafts created or updated, items deployed, test runs and their results)
|
||||
- Errors you ran into and how you fixed them
|
||||
- Specific user feedback, especially if the user told you to do something differently
|
||||
2. Double-check for technical accuracy and completeness.
|
||||
|
||||
Your summary should include the following sections:
|
||||
|
||||
1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail.
|
||||
2. Key Technical Concepts: List important technical concepts, technologies, and frameworks discussed.
|
||||
3. Workspace Items and Code: Enumerate the workspace items inspected, drafted, updated, deployed, or test-run — each by its path and kind (script / flow / app / resource / variable / trigger / schedule) — noting what was done and why. Include full code snippets for runnables (script code, flow inline scripts, app inline runnables) wherever code was written or changed.
|
||||
4. Errors and fixes: List errors encountered and how they were fixed, including any user feedback.
|
||||
5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts.
|
||||
6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the user's feedback and changing intent.
|
||||
7. Pending Tasks: Outline any pending tasks you have explicitly been asked to work on.
|
||||
8. Current Work: Describe precisely what was being worked on immediately before this summary, paying special attention to the most recent messages. Include item paths and code snippets where applicable.
|
||||
9. Context for Continuing Work: Summarize any context, decisions, or state needed to understand and continue the work in subsequent messages — including which items are still drafts versus deployed and the exact paths involved. If there is a clear next step directly in line with the user's most recent explicit request, state it and include a direct quote from the most recent conversation showing where you left off.
|
||||
|
||||
Structure your output like this:
|
||||
|
||||
<analysis>
|
||||
[Your thought process, ensuring all points are covered thoroughly and accurately]
|
||||
</analysis>
|
||||
|
||||
<summary>
|
||||
1. Primary Request and Intent:
|
||||
[Detailed description]
|
||||
|
||||
2. Key Technical Concepts:
|
||||
- [Concept]
|
||||
|
||||
3. Workspace Items and Code:
|
||||
- [path + kind, e.g. u/alice/sync_orders (script)]
|
||||
- [What was done: read / draft created / draft updated / deployed / test-run result]
|
||||
- [Why it matters]
|
||||
- [Code snippet, for runnables]
|
||||
|
||||
4. Errors and fixes:
|
||||
- [Error]: [How you fixed it]
|
||||
|
||||
5. Problem Solving:
|
||||
[Description]
|
||||
|
||||
6. All user messages:
|
||||
- [Non-tool-result user message]
|
||||
|
||||
7. Pending Tasks:
|
||||
- [Task]
|
||||
|
||||
8. Current Work:
|
||||
[Precise description of current work]
|
||||
|
||||
9. Context for Continuing Work:
|
||||
[Key context, decisions, or state needed to continue]
|
||||
</summary>
|
||||
|
||||
Please provide your summary following this structure, ensuring precision and thoroughness.`
|
||||
|
||||
/** Prompt sent as the final user message of the summarization request. */
|
||||
export function getCompactionSummaryPrompt(): string {
|
||||
return NO_TOOLS_PREAMBLE + SUMMARY_PROMPT + NO_TOOLS_TRAILER
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the <analysis> drafting scratchpad and unwraps the <summary> block.
|
||||
* Falls back to the trimmed raw text when the model didn't use the tags, so a
|
||||
* well-formed-but-untagged summary is still usable.
|
||||
*/
|
||||
export function formatCompactSummary(raw: string): string {
|
||||
let formatted = raw.replace(/<analysis>[\s\S]*?<\/analysis>/gi, '')
|
||||
|
||||
const summaryMatch = formatted.match(/<summary>([\s\S]*?)<\/summary>/i)
|
||||
if (summaryMatch) {
|
||||
formatted = (summaryMatch[1] ?? '').trim()
|
||||
}
|
||||
|
||||
// Collapse the blank-line runs left behind by stripping the analysis block.
|
||||
return formatted.replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a formatted summary as the content of the user message that replaces
|
||||
* the summarized prefix in the conversation.
|
||||
*/
|
||||
export function buildSummaryMessageContent(formattedSummary: string): string {
|
||||
return `This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation. Recent messages after the summary are preserved verbatim.
|
||||
|
||||
${formattedSummary}
|
||||
|
||||
Continue the conversation from where it left off. Do not re-introduce the summary or recap it to the user; pick up the work as if the break never happened.`
|
||||
}
|
||||
@@ -525,7 +525,22 @@ export type AssistantDisplayMessage = BaseDisplayMessage & {
|
||||
streaming?: boolean
|
||||
}
|
||||
|
||||
export type DisplayMessage = UserDisplayMessage | ToolDisplayMessage | AssistantDisplayMessage
|
||||
/**
|
||||
* Compaction boundary: replaces the summarized prefix in BOTH displayMessages
|
||||
* and the API messages (where it is a plain user message). It carries no index
|
||||
* because it is never a restart target — only the surviving tail's user
|
||||
* messages are rewound to.
|
||||
*/
|
||||
export type SummaryDisplayMessage = {
|
||||
role: 'summary'
|
||||
content: string
|
||||
}
|
||||
|
||||
export type DisplayMessage =
|
||||
| UserDisplayMessage
|
||||
| ToolDisplayMessage
|
||||
| AssistantDisplayMessage
|
||||
| SummaryDisplayMessage
|
||||
|
||||
// A tool message whose askUserQuestion is still awaiting an answer: the AI loop
|
||||
// is paused on the user. Drives the question card's interactivity, the
|
||||
|
||||
@@ -523,6 +523,14 @@ export const settings: Record<string, Setting[]> = {
|
||||
fieldType: 'smtp_connect',
|
||||
storage: 'setting',
|
||||
ee_only: ''
|
||||
},
|
||||
{
|
||||
label: 'Disable workspace invite emails',
|
||||
description:
|
||||
'Do not send email notifications when a user is invited or added to a workspace. Useful for automated workflows that add users programmatically.',
|
||||
key: 'disable_workspace_invite_emails',
|
||||
fieldType: 'boolean',
|
||||
storage: 'setting'
|
||||
}
|
||||
],
|
||||
'Auth/OAuth/SAML': [
|
||||
|
||||
Reference in New Issue
Block a user