1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
//! # Gateway configuration
//!
//! WHY separate from AppConfig: Gateway-specific settings (platform
//! credentials, adapter toggles, delivery preferences) don't belong
//! in the generic agent config. This keeps concerns separated.
use std::time::Duration;
use lingshu_core::config::{GroupPolicy, UnauthorizedDmBehavior};
use serde::Deserialize;
/// Progressive streaming and progress-notification settings for the gateway.
///
/// WHY a nested struct: These settings only apply to the async dispatch loop —
/// they're logically separate from server/session settings and map cleanly
/// to a `gateway.streaming:` YAML block.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct GatewayStreamingConfig {
/// Enable progressive delivery for edit-capable platforms (Telegram, Discord, Slack).
///
/// When `true` (default), the gateway uses `Agent::chat_streaming()` and
/// progressively edits the first reply message as tokens arrive.
/// When `false`, the full response is sent as a single message (old behaviour).
pub enabled: bool,
/// Minimum pause between consecutive edits, in milliseconds (default: 300 ms).
///
/// Prevents flooding the Telegram/Discord edit endpoint and triggering
/// 429 Too Many Requests responses. Mirrors hermes-agent's `edit_interval`.
pub edit_interval_ms: u64,
/// Minimum characters buffered before triggering an intermediate edit (default: 40).
///
/// Balances responsiveness against API call count.
pub buffer_threshold: usize,
/// Cursor appended to partial messages to signal "still typing" (default: " ▉").
pub cursor: String,
/// Send a tool-progress notification when a tool starts executing (default: true).
///
/// The notification is a short, separate message (e.g. "🔧 web_search…").
/// On edit-capable platforms this can be the same message being updated;
/// on non-edit platforms it is a standalone status message.
pub tool_progress: bool,
/// Show the agent's reasoning/thinking block before the final answer (default: false).
///
/// When `true`, reasoning text is forwarded to the platform before the
/// response tokens start. Useful for debugging; generally off for production.
pub show_reasoning: bool,
/// Max characters of background-process tail in gateway status pushes (default: 500).
pub bg_tail_chars: usize,
}
impl Default for GatewayStreamingConfig {
fn default() -> Self {
Self {
enabled: true,
edit_interval_ms: 300,
buffer_threshold: 40,
cursor: " ▉".into(),
tool_progress: true,
show_reasoning: false,
bg_tail_chars: 500,
}
}
}
impl GatewayStreamingConfig {
/// Edit interval as a `Duration`.
pub fn edit_interval(&self) -> Duration {
Duration::from_millis(self.edit_interval_ms)
}
}
/// Gateway-specific configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct GatewayConfig {
/// Host to bind the health/API server to
pub host: String,
/// Port for the health/API server
pub port: u16,
/// Session idle timeout in seconds (default: 1 hour)
pub session_idle_timeout_secs: u64,
/// Session cleanup interval in seconds (default: 5 minutes)
pub cleanup_interval_secs: u64,
/// Default model for gateway sessions
pub default_model: String,
/// Whether to enable the webhook adapter (always-on)
pub webhook_enabled: bool,
/// Default group chat policy for platforms without an explicit override.
pub group_policy: GroupPolicy,
/// Behavior when an unauthorized user sends a direct message.
pub unauthorized_dm_behavior: UnauthorizedDmBehavior,
/// Streaming and tool-progress settings.
pub streaming: GatewayStreamingConfig,
/// How to handle a second user message that arrives while the agent is
/// already processing a prior message for the same session.
pub second_message_mode: SecondMessageMode,
}
/// Governs how a second incoming message is handled when the agent is already
/// running for that session.
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum SecondMessageMode {
/// Queue the message and deliver it after the current response finishes.
/// This is the default and preserves the original FIFO behaviour.
#[default]
Queue,
/// Inject the message as a `Redirect` `SteeringEvent` into the running
/// agent loop so it can course-correct mid-turn.
Steer,
/// Cancel the running agent immediately and start a fresh turn with the
/// new message.
Interrupt,
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".into(),
port: 8080,
session_idle_timeout_secs: 3600,
cleanup_interval_secs: 300,
default_model: "anthropic/claude-sonnet-4-20250514".into(),
webhook_enabled: true,
group_policy: GroupPolicy::default(),
unauthorized_dm_behavior: UnauthorizedDmBehavior::default(),
streaming: GatewayStreamingConfig::default(),
second_message_mode: SecondMessageMode::default(),
}
}
}
impl GatewayConfig {
/// Session idle timeout as a Duration.
pub fn idle_timeout(&self) -> Duration {
Duration::from_secs(self.session_idle_timeout_secs)
}
/// Cleanup interval as a Duration.
pub fn cleanup_interval(&self) -> Duration {
Duration::from_secs(self.cleanup_interval_secs)
}
/// Bind address as "host:port".
pub fn bind_addr(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config() {
let cfg = GatewayConfig::default();
assert_eq!(cfg.port, 8080);
assert_eq!(cfg.idle_timeout(), Duration::from_secs(3600));
assert_eq!(cfg.bind_addr(), "127.0.0.1:8080");
}
#[test]
fn custom_config() {
let cfg = GatewayConfig {
host: "0.0.0.0".into(),
port: 9090,
session_idle_timeout_secs: 600,
..Default::default()
};
assert_eq!(cfg.bind_addr(), "0.0.0.0:9090");
assert_eq!(cfg.idle_timeout(), Duration::from_secs(600));
}
}