Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion charts/openab/templates/gateway.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,14 @@ spec:
value: "false"
{{- end }}
{{- end }}
{{- $hasGoogleChat := or (($cfg.gateway).googleChat).saKeyJson (($cfg.gateway).googleChat).accessToken (($cfg.gateway).googleChat).audience }}
{{- $hasGoogleChat := or (($cfg.gateway).googleChat).saKeyJson (($cfg.gateway).googleChat).accessToken (($cfg.gateway).googleChat).audience (($cfg.gateway).googleChat).useAdc }}
{{- if $hasGoogleChat }}
- name: GOOGLE_CHAT_ENABLED
value: "true"
{{- if (($cfg.gateway).googleChat).useAdc }}
- name: GOOGLE_CHAT_USE_ADC
value: "true"
{{- end }}
{{- if (($cfg.gateway).googleChat).audience }}
- name: GOOGLE_CHAT_AUDIENCE
value: {{ ($cfg.gateway).googleChat.audience | quote }}
Expand Down
1 change: 1 addition & 0 deletions charts/openab/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ agents:
audience: "" # JWT audience → GOOGLE_CHAT_AUDIENCE (set to your webhook URL to enable JWT verification)
saKeyJson: "" # Service account key JSON string → GOOGLE_CHAT_SA_KEY_JSON (recommended, auto-refresh)
accessToken: "" # Static OAuth2 access token → GOOGLE_CHAT_ACCESS_TOKEN (fallback, 1-hour TTL)
useAdc: false # Keyless ADC → GOOGLE_CHAT_USE_ADC. Mints chat.bot token from the pod's own GCP identity (GCE metadata + IAM Credentials generateAccessToken). No SA key file. Needs roles/iam.serviceAccountTokenCreator on the SA over itself + iamcredentials.googleapis.com enabled. Ignored when saKeyJson is set and loads; a key that fails to load falls back to ADC with a warning (see docs/google-chat.md Option C).
webhookPath: "" # Gateway default: /webhook/googlechat → GOOGLE_CHAT_WEBHOOK_PATH
# WeCom (企业微信) adapter config (gateway-side env vars)
# See docs/wecom.md for full setup guide
Expand Down
1 change: 1 addition & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto-
# sa_key_json = "${GOOGLE_CHAT_SA_KEY_JSON}" # inline SA key; wins over sa_key_file
# sa_key_file = "/etc/openab/sa.json" # env fallback: GOOGLE_CHAT_SA_KEY_FILE
# access_token = "${GOOGLE_CHAT_ACCESS_TOKEN}" # static token alternative
# use_adc = true # keyless ADC: mint chat.bot token from the workload's own GCP identity (GCE metadata + IAM Credentials); no SA key. env fallback: GOOGLE_CHAT_USE_ADC. Ignored when a configured sa_key_* loads; a key that fails to load falls back to ADC with a warning (docs/google-chat.md Option C).
# audience = "projects/<n>/..." # enables webhook JWT verification (L1)
# webhook_path = "/webhook/googlechat" # env fallback: GOOGLE_CHAT_WEBHOOK_PATH
# allow_all_users = false # env fallback: GOOGLE_CHAT_ALLOW_ALL_USERS
Expand Down
50 changes: 41 additions & 9 deletions crates/openab-core/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ fn reply_message_limit(platform: &str, adapter_limit: usize) -> usize {
}
}

/// Whether to use cosmetic streaming (placeholder + in-place edits) for
/// `platform`, given the adapter's own preference. Forces send-once for:
/// - `acp`: streams append-only `agent_message_chunk` deltas, not edits.
/// - platforms in `NON_STREAMING_PLATFORMS` — no message-edit API, or an edit
/// API that cannot be driven per-token (e.g. `googlechat`) — see
/// `NON_STREAMING_PLATFORMS` for the per-platform rationale and
/// `platform_supports_streaming`.
///
/// This is the embedded/unified dispatch gate; the WebSocket
/// `run_gateway_adapter` path applies the same `platform_supports_streaming`
/// check but has no `acp` case (ACP is embedded-only). The two gates are
/// siblings: adding a platform to `NON_STREAMING_PLATFORMS` covers both
/// paths, while an embedded-only carve-out belongs here.
fn resolve_streaming(platform: &str, adapter_prefers_streaming: bool) -> bool {
platform != "acp"
&& crate::gateway::platform_supports_streaming(platform)
&& adapter_prefers_streaming
}

/// Parse `[[key:value]]` directives from the beginning of agent output.
/// Returns parsed directives and the remaining content (directives stripped).
pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) {
Expand Down Expand Up @@ -701,15 +720,14 @@ impl AdapterRouter {
let adapter = adapter.clone();
let thread_channel = thread_channel.clone();
let message_limit = reply_message_limit(&thread_channel.platform, adapter.message_limit());
// ACP must not inherit the unified adapter's Telegram streaming flag (wrong
// coupling): it streams append-only `agent_message_chunk` deltas built from the
// post+edit (`edit_message` snapshot) path, i.e. streaming=false. Decide it
// explicitly by platform rather than by whatever Telegram happens to be set to.
let streaming = if thread_channel.platform == "acp" {
false
} else {
adapter.use_streaming(other_bot_present)
};
// Decide streaming explicitly by platform, not by whatever the unified
// adapter's Telegram flag happens to be. ACP streams append-only deltas
// (not cosmetic edits); Google Chat / LINE can't sustain in-place edits.
// See `resolve_streaming`.
let streaming = resolve_streaming(
&thread_channel.platform,
adapter.use_streaming(other_bot_present),
);
// Keep the full turn text (incl. inter-tool narration) when streaming
// (it was already shown live) OR when `[reactions] narration_display` is
// set. Otherwise a send-once turn delivers only the final answer block.
Expand Down Expand Up @@ -1746,6 +1764,20 @@ mod tests {
assert_eq!(crate::format::split_message(&long, reply_message_limit("acp", 4096)).len(), 1);
}

#[test]
fn resolve_streaming_forces_send_once_for_acp_and_googlechat() {
// Editable platforms honor the adapter's own streaming preference.
assert!(resolve_streaming("discord", true));
assert!(!resolve_streaming("discord", false));
assert!(resolve_streaming("telegram", true));
// ACP streams append-only deltas, not cosmetic edits → always send-once.
assert!(!resolve_streaming("acp", true));
// Google Chat: synthetic id can't be patched (400 INVALID_ARGUMENT) → send-once regardless of pref.
assert!(!resolve_streaming("googlechat", true));
// LINE: no edit API → send-once.
assert!(!resolve_streaming("line", true));
}

#[test]
fn select_delivery_text_send_once_keeps_only_final_block() {
// Simulates: narration "n1" → tool (answer_start→2) → narration "n2"
Expand Down
24 changes: 24 additions & 0 deletions crates/openab-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,14 @@ pub struct GoogleChatConfig {
/// checked when `allow_all_users` resolves to `false`. Env fallback:
/// `GOOGLE_CHAT_ALLOWED_USERS` (comma-separated).
pub allowed_users: Option<Vec<String>>,
/// Use keyless ADC (GCE metadata server + IAM Credentials
/// `generateAccessToken` self-impersonation) to mint the `chat.bot` token,
/// instead of a SA key file or a static token. Env fallback:
/// `GOOGLE_CHAT_USE_ADC` (`true`/`1`; default false). Ignored when a
/// configured SA key loads successfully — the SA key takes precedence; a
/// key that fails to load falls back to ADC with a warning naming the
/// identity switch (see docs/google-chat.md Option C).
pub use_adc: Option<bool>,
}

/// Fully resolved Google Chat settings (config → env → default applied).
Expand All @@ -1234,6 +1242,7 @@ pub struct ResolvedGoogleChat {
pub sa_key_json: Option<String>,
pub sa_key_file: Option<String>,
pub access_token: Option<String>,
pub use_adc: bool,
pub audience: Option<String>,
pub webhook_path: String,
pub allow_all_users: bool,
Expand All @@ -1259,6 +1268,11 @@ impl GoogleChatConfig {
sa_key_json: opt_str(&self.sa_key_json, "GOOGLE_CHAT_SA_KEY_JSON"),
sa_key_file: opt_str(&self.sa_key_file, "GOOGLE_CHAT_SA_KEY_FILE"),
access_token: opt_str(&self.access_token, "GOOGLE_CHAT_ACCESS_TOKEN"),
use_adc: self.use_adc.unwrap_or_else(|| {
std::env::var("GOOGLE_CHAT_USE_ADC")
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
}),
audience: opt_str(&self.audience, "GOOGLE_CHAT_AUDIENCE"),
webhook_path: opt_str(&self.webhook_path, "GOOGLE_CHAT_WEBHOOK_PATH")
.unwrap_or_else(|| "/webhook/googlechat".into()),
Expand Down Expand Up @@ -2966,6 +2980,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"]
"GOOGLE_CHAT_SA_KEY_JSON",
"GOOGLE_CHAT_SA_KEY_FILE",
"GOOGLE_CHAT_ACCESS_TOKEN",
"GOOGLE_CHAT_USE_ADC",
"GOOGLE_CHAT_AUDIENCE",
"GOOGLE_CHAT_WEBHOOK_PATH",
] {
Expand All @@ -2974,9 +2989,18 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"]
// --- defaults ---
let r = GoogleChatConfig::default().resolve();
assert!(!r.enabled);
assert!(!r.use_adc);
assert!(r.audience.is_none());
assert_eq!(r.webhook_path, "/webhook/googlechat");

// --- use_adc: config value resolves without touching env ---
let r = GoogleChatConfig {
use_adc: Some(true),
..Default::default()
}
.resolve();
assert!(r.use_adc);

// --- config wins over env ---
std::env::set_var("GOOGLE_CHAT_ENABLED", "true");
std::env::set_var("GOOGLE_CHAT_AUDIENCE", "env-aud");
Expand Down
44 changes: 31 additions & 13 deletions crates/openab-core/src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,29 @@ fn platform_acks_writes(platform: &str) -> bool {
/// for a *capability*. The right long-term model is a capability handshake at
/// gateway-connect time ("can this adapter edit messages?"); until that exists,
/// any new gateway platform that lacks a message-edit API MUST be added here.
const NON_EDITABLE_PLATFORMS: &[&str] = &["line", "lineworks"];
/// Platforms where cosmetic (typewriter) streaming — a placeholder message
/// then rapid in-place edits — is not viable, so replies are forced send-once:
/// - `line` / `lineworks`: no message-edit API at all.
/// - `googlechat`: has an edit API, but the unified adapter's synthetic
/// `unified_<hex>` message id is not a valid resource name, so `patch`
/// rejects it with 400 INVALID_ARGUMENT before any edit applies; the
/// documented 1 write/sec-per-space quota (create + patch + delete
/// combined) further constrains high-frequency editing.
/// See <https://developers.google.com/workspace/chat/limits>.
const NON_STREAMING_PLATFORMS: &[&str] = &["line", "lineworks", "googlechat"];

/// Whether cosmetic streaming (placeholder + in-place edits) is possible on
/// `platform`. See `NON_EDITABLE_PLATFORMS`.
fn platform_supports_streaming(platform: &str) -> bool {
!NON_EDITABLE_PLATFORMS.contains(&platform)
/// `platform`. See `NON_STREAMING_PLATFORMS`. `pub(crate)` so the shared
/// dispatch path (`AdapterRouter::stream_prompt_blocks`) can force send-once on
/// these platforms too, not just the WebSocket `run_gateway_adapter` path.
///
/// Sibling gate: the embedded/unified dispatch path wraps this in
/// `adapter::resolve_streaming`, which additionally forces send-once for
/// `acp` (embedded-only, streams append-only deltas). An embedded-only
/// non-streaming platform must be handled there — adding it to the shared
/// list above covers both paths, but a platform-specific carve-out does not.
pub(crate) fn platform_supports_streaming(platform: &str) -> bool {
!NON_STREAMING_PLATFORMS.contains(&platform)
}

/// Shared filter parameters for gateway event gating.
Expand Down Expand Up @@ -1669,17 +1686,18 @@ mod tests {
assert!(!platform_supports_streaming("line"));
}

#[test]
fn googlechat_rate_limit_forces_send_once() {
// Google Chat has an edit API, but the unified adapter's synthetic
// message id is not a valid resource name, so patch returns 400
// INVALID_ARGUMENT; the documented 1 write/sec-per-space quota further
// constrains high-frequency editing. Force send-once.
assert!(!platform_supports_streaming("googlechat"));
}

#[test]
fn editable_platforms_still_allow_streaming() {
for platform in [
"telegram",
"slack",
"discord",
"feishu",
"teams",
"googlechat",
"wecom",
] {
for platform in ["telegram", "slack", "discord", "feishu", "teams", "wecom"] {
assert!(
platform_supports_streaming(platform),
"{platform} should still support streaming",
Expand Down
Loading
Loading