Conversation
…tech, patch_skill, self_update_to_branch, user model, skill extraction) - Feature 1: Post-task skill extractor (auto-generates skills from tool-heavy conversations) - Feature 2: Skill self-patch + try_new_tech sandboxed experiment tool - Feature 3: Honcho-style user model (USER.md in system prompt + periodic updates) - Feature 4: self_update_to_branch tool for git-based self-update Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/6a30a406-51fa-4b97-a3e8-fa9c50370f95 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
…njection, depth limit, logging Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/6a30a406-51fa-4b97-a3e8-fa9c50370f95 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
|
@copilot use superpower debug the telegram update message issue, it is now can stream smoothly but once the content is long , it will separate another message which is normal behaviour for telegram, but once the response is complete, it seems like clean up the text show that I can really see the full message suddenly only part of the message, I am think if it is clean up thinking or tool use message logic? But I disabled the /vebose function. Please research and investigate the issue. |
When the streaming buffer exceeded 3800 chars, the old code sent the entire buffer as a NEW message then cleared the buffer. Subsequent edits replaced that message's content with only the small post-split tokens, causing the message to visually shrink from ~3800 chars to a few dozen. Fix: at the split threshold, finalize (edit) the current message with its accumulated content, then reset current_msg_id = None so the next batch of tokens creates a fresh message. This eliminates both the content duplication across messages and the dramatic shrinking effect. Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/4b1f0a8e-3973-4890-960c-e11cb4794705 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Found the root cause in Bug: When buffer exceeded 3800 chars, the code sent the entire buffer as a new message, then cleared the buffer. On the next 500ms edit, the new message was replaced with only the small post-split tokens (~60 chars), causing a dramatic visual shrink from ~3800 chars to almost nothing. This also duplicated content between the old and new messages. Fix (0fddd4c): At the split threshold, the code now finalizes (edits) the current message with its accumulated content, then resets This is unrelated to thinking/tool-use cleanup — the |
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/4b1f0a8e-3973-4890-960c-e11cb4794705 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
- setup/index.html: swap Notion catalog entry from deprecated npx @notionhq/notion-mcp-server to HTTP url:'https://mcp.notion.com/mcp' with authTokenVar:'NOTION_TOKEN' - Extend generateToml() to emit url+auth_token for HTTP catalog entries - Extend card renderer to show 'HTTP · <url>' and authToken input - Extend loadExistingConfig() to map auth_token back to authTokenVar - Add Notion setup guide modal (3 steps: create integration, copy token, share pages) with link to https://developers.notion.com/guides/mcp/mcp - Wire __NOTION_GUIDE_BUTTON__ in card renderer - Add notion-modal to Escape keydown handler - config.example.toml: add commented Notion HTTP MCP example block - tool_notifier.rs: add notion → 📝 emoji in friendly_tool_name() Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/c91d5018-6090-48d2-94f4-acc1d43f87bb Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Previously, connect_http() called .auth_header(config.auth_token
.unwrap_or_default()), which turned None into "" and stored it as
Some(""). reqwest then sent "Authorization: Bearer " (empty token),
causing Notion's remote MCP to respond with 401 invalid_token.
Fix: only call .auth_header() when auth_token is Some and non-empty.
Also emit a tracing::warn when an HTTP server is configured without any
token, to make misconfiguration easier to spot in logs.
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/c948e282-b465-4eff-b8bf-a8742af001db
Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
- Documented new features for Google Calendar, Email, and Drive integration tools. - Updated planned features section to reflect the removal of Google integration tools as a future task.
|
Claude finished @chinkan's task in 2m 51s —— View job Code Review: PR #34 — feat: self-learning + self-update features + fix telegram stream split bug
OverviewThis PR adds three significant features: self-learning (skill extraction + user model), self-update (git pull + rebuild via bot command), and OAuth 2.0 for MCP setup — plus a correctness fix for the Telegram streaming split bug. The code is well-structured and tested. Below are findings from most to least critical. 🔴 Critical Issues1. Mutex held across async network I/O in
let mut sessions = st.oauth_sessions.lock().await; // lock acquired
let session = sessions.get_mut(¶ms.state)?;
// ... build token_params from session fields ...
let token_result = st.http_client.post(&session.token_endpoint)
.form(&token_params)
.send() // 🔴 network I/O while locked
.await;
// lock released only hereThis blocks all concurrent 🟡 Significant Issues2. Skill extraction / user model update block the response ( The doc comment on let _extraction_result = tokio::time::timeout(
std::time::Duration::from_secs(60),
crate::learning::post_task_skill_extractor(...)
)
.await; // 🟡 blocks response deliverySame issue for the user model update block. These should be wrapped in 3. Each call creates 4. The 🟠 Minor Issues5. OAuth scope parameter missing in auth URL ( The authorization URL is built without a 6. User model content injected unsanitized into system prompt ( The 7. User model validation is too permissive ( if !new_content.contains("---") || new_content.trim().is_empty() {A response containing 8. The log ends with "Restart to activate the new version" but no restart mechanism is provided. The user must manually restart the bot. This is noted in docs but a 9. Stash on If uncommitted changes exist, they're stashed before the update — but the stash is never popped on success or failure. The stash accumulates in git history on repeated updates. Add ✅ What's Working Well
SummaryThe PR's core functionality is solid and the Telegram bug fix is correct. The most actionable issues before merge are: (1) the mutex-held-across-network-IO bug in |
|
Claude finished @chinkan's task in 3m 48s —— View job Code Review: PR #34 — feat: self-learning + self-update features + fix telegram stream split bug
|
There was a problem hiding this comment.
Pull request overview
Adds self-learning and self-update capabilities to the RustFox agent, expands the setup wizard with an OAuth flow (Notion-focused) for HTTP MCP servers, and fixes Telegram streaming message splitting behavior.
Changes:
- Introduce a new
learningmodule (skill extraction, skill patching, user model maintenance, self-update) and wire it into the agent + scheduler. - Extend the setup wizard UI/server to support OAuth (DCR + PKCE) and update the Notion catalog entry to use the official HTTP MCP endpoint + token autofill.
- Fix Telegram stream splitting to avoid “message shrinking” by finalizing the current message before starting a new one.
Reviewed changes
Copilot reviewed 13 out of 15 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| src/tools.rs | Adds new built-in tool definitions (try_new_tech, self_update_to_branch, patch_skill). |
| src/scheduler/tasks.rs | Adds weekly cron task to refresh the user model file. |
| src/platform/tool_notifier.rs | Adds a Notion icon mapping for tool UX. |
| src/platform/telegram.rs | Fixes streaming split logic to finalize the current message rather than sending a new full-buffer message. |
| src/mcp.rs | Avoids sending an empty Authorization: Bearer header; changes auth header handling. |
| src/main.rs | Registers the new learning module and passes learning config into task registration. |
| src/learning.rs | New self-learning + self-update implementation (skill extraction/patching, user model updates, git self-update). |
| src/config.rs | Adds [learning] config with defaults. |
| src/bin/setup.rs | Adds OAuth endpoints + PKCE helpers + shared OAuth session state to the setup wizard server. |
| setup/index.html | Updates Notion to official HTTP MCP + adds “Connect with Notion” OAuth flow and token autofill. |
| README.md | Marks Google integration tools as implemented. |
| config.example.toml | Adds Notion HTTP MCP example and documents learning config options. |
| Cargo.toml / Cargo.lock | Adds OAuth helper deps (rand, sha2, base64). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let msgs_clone = messages.clone(); | ||
| // Run inline with a timeout so it doesn't block too long. | ||
| let _extraction_result = tokio::time::timeout( | ||
| std::time::Duration::from_secs(60), | ||
| crate::learning::post_task_skill_extractor( | ||
| &self.llm, | ||
| &self.config.skills.directory, | ||
| &self.skills, | ||
| &msgs_clone, | ||
| tool_call_count, | ||
| ), | ||
| ) | ||
| .await; |
There was a problem hiding this comment.
This block is labeled “background” but it awaits the extractor via timeout(...). That means the user-facing response is delayed until the extractor finishes or 60s elapses. If the intent is non-blocking, spawn a detached task (and apply the timeout inside that task) so returning final_content isn’t held up.
| let msgs_clone = messages.clone(); | |
| // Run inline with a timeout so it doesn't block too long. | |
| let _extraction_result = tokio::time::timeout( | |
| std::time::Duration::from_secs(60), | |
| crate::learning::post_task_skill_extractor( | |
| &self.llm, | |
| &self.config.skills.directory, | |
| &self.skills, | |
| &msgs_clone, | |
| tool_call_count, | |
| ), | |
| ) | |
| .await; | |
| let llm = self.llm.clone(); | |
| let skills_directory = self.config.skills.directory.clone(); | |
| let skills = self.skills.clone(); | |
| let msgs_clone = messages.clone(); | |
| tokio::spawn(async move { | |
| let _extraction_result = tokio::time::timeout( | |
| std::time::Duration::from_secs(60), | |
| crate::learning::post_task_skill_extractor( | |
| &llm, | |
| &skills_directory, | |
| &skills, | |
| &msgs_clone, | |
| tool_call_count, | |
| ), | |
| ) | |
| .await; | |
| }); |
| let _update_result = tokio::time::timeout( | ||
| std::time::Duration::from_secs(60), | ||
| crate::learning::update_user_model( | ||
| &self.llm, | ||
| &self.memory, | ||
| &self.config.learning.user_model_path, | ||
| ), | ||
| ) | ||
| .await; |
There was a problem hiding this comment.
The periodic user model update is also executed inline with an awaited timeout, so it can delay the final reply by up to 60s on every Nth user message. Consider running this update in a spawned background task (or queue it onto the scheduler) to avoid impacting request latency.
| let _update_result = tokio::time::timeout( | |
| std::time::Duration::from_secs(60), | |
| crate::learning::update_user_model( | |
| &self.llm, | |
| &self.memory, | |
| &self.config.learning.user_model_path, | |
| ), | |
| ) | |
| .await; | |
| let llm = self.llm.clone(); | |
| let memory = self.memory.clone(); | |
| let user_model_path = self.config.learning.user_model_path.clone(); | |
| tokio::spawn(async move { | |
| match tokio::time::timeout( | |
| std::time::Duration::from_secs(60), | |
| crate::learning::update_user_model( | |
| &llm, | |
| &memory, | |
| &user_model_path, | |
| ), | |
| ) | |
| .await | |
| { | |
| Ok(Ok(())) => { | |
| debug!("Periodic user model update completed"); | |
| } | |
| Ok(Err(err)) => { | |
| warn!(error = %err, "Periodic user model update failed"); | |
| } | |
| Err(_) => { | |
| warn!("Periodic user model update timed out"); | |
| } | |
| } | |
| }); |
| /// Analyze a completed agentic loop and, if the conversation used enough tool | ||
| /// calls and contains a reusable pattern, auto-generate a new skill in `skills/`. | ||
| /// | ||
| /// Runs in the background (via `tokio::spawn`) so it never blocks the user. |
There was a problem hiding this comment.
The doc comment says this “Runs in the background (via tokio::spawn)”, but the function itself doesn’t spawn and callers may await it directly (as in Agent::process_message). Either update the doc to describe the actual behavior, or make post_task_skill_extractor spawn internally if it must never block.
| /// Runs in the background (via `tokio::spawn`) so it never blocks the user. | |
| /// This function performs the extraction work when awaited. Callers that want | |
| /// it to run in the background should spawn it explicitly with `tokio::spawn`. |
| // Build SKILL.md content with frontmatter. | ||
| let tags_yaml = if tags.is_empty() { | ||
| "[]".to_string() | ||
| } else { | ||
| format!("[{}]", tags.join(", ")) | ||
| }; | ||
| let skill_content = format!( | ||
| "---\nname: {name}\ndescription: {description}\ntags: {tags}\n---\n\n{body}\n", | ||
| name = name, | ||
| description = description, |
There was a problem hiding this comment.
Generated SKILL.md frontmatter is assembled with unquoted/interpolated description and tags. If the LLM outputs characters like :/#/] or commas inside a tag, the repo’s frontmatter parser (simple string-based parsing) can fail to load the generated skill. Consider sanitizing/quoting these fields (e.g., YAML-quoting description and emitting tags as quoted items) before writing the file.
| // Build SKILL.md content with frontmatter. | |
| let tags_yaml = if tags.is_empty() { | |
| "[]".to_string() | |
| } else { | |
| format!("[{}]", tags.join(", ")) | |
| }; | |
| let skill_content = format!( | |
| "---\nname: {name}\ndescription: {description}\ntags: {tags}\n---\n\n{body}\n", | |
| name = name, | |
| description = description, | |
| fn yaml_single_quoted(value: &str) -> String { | |
| format!("'{}'", value.replace('\'', "''")) | |
| } | |
| // Build SKILL.md content with frontmatter. | |
| let tags_yaml = if tags.is_empty() { | |
| "[]".to_string() | |
| } else { | |
| format!( | |
| "[{}]", | |
| tags.iter() | |
| .map(|tag| yaml_single_quoted(tag)) | |
| .collect::<Vec<_>>() | |
| .join(", ") | |
| ) | |
| }; | |
| let skill_content = format!( | |
| "---\nname: {name}\ndescription: {description}\ntags: {tags}\n---\n\n{body}\n", | |
| name = name, | |
| description = yaml_single_quoted(&description), |
| pub async fn self_patch_skill( | ||
| skills_dir: &Path, | ||
| skill_name: &str, | ||
| patch_content: &str, | ||
| skills: &tokio::sync::RwLock<SkillRegistry>, | ||
| ) -> Result<String> { | ||
| let skill_path = skills_dir.join(skill_name).join("SKILL.md"); | ||
|
|
||
| if !skill_path.exists() { | ||
| anyhow::bail!("Skill '{}' does not exist", skill_name); | ||
| } |
There was a problem hiding this comment.
self_patch_skill joins skills_dir.join(skill_name) without validating skill_name. A crafted name containing path separators (e.g. ../) could escape the skills directory and overwrite arbitrary files (the exists() check doesn’t prevent this). Validate skill_name as a safe directory name and/or canonicalize and ensure the resolved path is within skills_dir before reading/writing.
| None => { | ||
| tracing::warn!( | ||
| "HTTP MCP server '{}' has no auth_token configured; \ | ||
| requests will be sent without an Authorization header", | ||
| config.name | ||
| ); | ||
| } |
There was a problem hiding this comment.
Warning on auth_token: None is likely noisy: auth_token is documented as optional (e.g. API keys can be embedded in the URL query), and the config tests include an HTTP server without auth_token. Consider removing this warn or downgrading it to debug, or only warning when an explicit "requires auth" flag is set for the server.
| # Get your integration token at https://www.notion.so/profile/integrations | ||
| # Note: the old @notionhq/notion-mcp-server npm package is deprecated. | ||
| # | ||
| # [[mcp_servers]] | ||
| # name = "notion" | ||
| # url = "https://mcp.notion.com/mcp" | ||
| # auth_token = "your-notion-integration-token" |
There was a problem hiding this comment.
This Notion example says “integration token” and uses your-notion-integration-token, but the setup wizard + PR description describe an OAuth 2.0 access token flow for the official Notion MCP endpoint. Please align the wording/example value so users don’t paste the wrong credential type.
| # Get your integration token at https://www.notion.so/profile/integrations | |
| # Note: the old @notionhq/notion-mcp-server npm package is deprecated. | |
| # | |
| # [[mcp_servers]] | |
| # name = "notion" | |
| # url = "https://mcp.notion.com/mcp" | |
| # auth_token = "your-notion-integration-token" | |
| # Complete the Notion OAuth setup, then paste your OAuth 2.0 access token here. | |
| # Note: the old @notionhq/notion-mcp-server npm package is deprecated. | |
| # | |
| # [[mcp_servers]] | |
| # name = "notion" | |
| # url = "https://mcp.notion.com/mcp" | |
| # auth_token = "your-notion-oauth-access-token" |
| let sandbox = &self.config.sandbox.allowed_directory; | ||
| let exp_id = uuid::Uuid::new_v4().to_string(); | ||
| let exp_dir = sandbox.join("experiments").join(&exp_id); | ||
|
|
There was a problem hiding this comment.
try_new_tech creates a unique experiment directory under allowed_directory/experiments but never removes it after execution. Over time this can accumulate many projects (and target/ dirs) and consume disk space. Consider cleaning up the directory after collecting output, or adding a retention policy/size limit.
| if tool_call_count < MIN_TOOL_CALLS_FOR_EXTRACTION { | ||
| return; | ||
| } |
There was a problem hiding this comment.
post_task_skill_extractor enforces MIN_TOOL_CALLS_FOR_EXTRACTION (hard-coded 5) in addition to the caller’s config.learning.skill_extraction_threshold. That makes configured thresholds <5 ineffective. Consider removing the hard-coded minimum or making it configurable.
| info!("Self-update requested: branch '{}'", branch); | ||
|
|
||
| // Determine project root from the current executable's location. | ||
| let project_root = match std::env::current_exe() { | ||
| Ok(exe) => { | ||
| // Navigate up from target/release/rustfox or target/debug/rustfox | ||
| let mut root = exe.clone(); | ||
| let mut depth = 0; | ||
| const MAX_DEPTH: usize = 10; | ||
| // Try to find Cargo.toml by walking up | ||
| loop { | ||
| if root.join("Cargo.toml").exists() { | ||
| break; | ||
| } | ||
| depth += 1; | ||
| if depth > MAX_DEPTH || !root.pop() { | ||
| // Fallback to current directory | ||
| root = std::env::current_dir() | ||
| .unwrap_or_else(|_| std::path::PathBuf::from(".")); | ||
| break; | ||
| } | ||
| } | ||
| root | ||
| } | ||
| Err(_) => { | ||
| std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) | ||
| } | ||
| }; | ||
|
|
||
| match crate::learning::self_update(&branch, &project_root).await { | ||
| Ok(log) => log, | ||
| Err(e) => format!("Self-update failed: {:#}", e), |
There was a problem hiding this comment.
branch is taken directly from tool arguments and passed into git checkout/git pull inside self_update. If branch starts with - it may be interpreted as a git flag, and unusual refnames can cause confusing failures. Consider validating branch against an allowlist pattern for git refnames (or prefixing args with --) before running git commands.
| info!("Self-update requested: branch '{}'", branch); | |
| // Determine project root from the current executable's location. | |
| let project_root = match std::env::current_exe() { | |
| Ok(exe) => { | |
| // Navigate up from target/release/rustfox or target/debug/rustfox | |
| let mut root = exe.clone(); | |
| let mut depth = 0; | |
| const MAX_DEPTH: usize = 10; | |
| // Try to find Cargo.toml by walking up | |
| loop { | |
| if root.join("Cargo.toml").exists() { | |
| break; | |
| } | |
| depth += 1; | |
| if depth > MAX_DEPTH || !root.pop() { | |
| // Fallback to current directory | |
| root = std::env::current_dir() | |
| .unwrap_or_else(|_| std::path::PathBuf::from(".")); | |
| break; | |
| } | |
| } | |
| root | |
| } | |
| Err(_) => { | |
| std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) | |
| } | |
| }; | |
| match crate::learning::self_update(&branch, &project_root).await { | |
| Ok(log) => log, | |
| Err(e) => format!("Self-update failed: {:#}", e), | |
| let is_valid_branch = !branch.is_empty() | |
| && !branch.starts_with('-') | |
| && !branch.starts_with('/') | |
| && !branch.ends_with('/') | |
| && !branch.ends_with('.') | |
| && !branch.ends_with(".lock") | |
| && !branch.contains("..") | |
| && !branch.contains("@{") | |
| && !branch.contains("//") | |
| && branch != "@" | |
| && !branch.chars().any(|c| { | |
| c.is_whitespace() | |
| || c.is_control() | |
| || matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\') | |
| }) | |
| && branch | |
| .chars() | |
| .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-')); | |
| if !is_valid_branch { | |
| format!("Self-update failed: invalid branch name '{}'", branch) | |
| } else { | |
| info!("Self-update requested: branch '{}'", branch); | |
| // Determine project root from the current executable's location. | |
| let project_root = match std::env::current_exe() { | |
| Ok(exe) => { | |
| // Navigate up from target/release/rustfox or target/debug/rustfox | |
| let mut root = exe.clone(); | |
| let mut depth = 0; | |
| const MAX_DEPTH: usize = 10; | |
| // Try to find Cargo.toml by walking up | |
| loop { | |
| if root.join("Cargo.toml").exists() { | |
| break; | |
| } | |
| depth += 1; | |
| if depth > MAX_DEPTH || !root.pop() { | |
| // Fallback to current directory | |
| root = std::env::current_dir() | |
| .unwrap_or_else(|_| std::path::PathBuf::from(".")); | |
| break; | |
| } | |
| } | |
| root | |
| } | |
| Err(_) => { | |
| std::env::current_dir() | |
| .unwrap_or_else(|_| std::path::PathBuf::from(".")) | |
| } | |
| }; | |
| match crate::learning::self_update(&branch, &project_root).await { | |
| Ok(log) => log, | |
| Err(e) => format!("Self-update failed: {:#}", e), | |
| } |
The Mutex guard was held across the async HTTP token-exchange request in `oauth_callback`, blocking all other concurrent lock acquisitions for the duration of the network call. Fix: clone the five needed fields (server_name, code_verifier, client_id, client_secret, token_endpoint) from the session inside a short scoped block so the lock is dropped before `.send().await`, then re-acquire the lock only to write `session.access_token` back. https://claude.ai/code/session_013tni4YG9cXkbJGXKTcdRPT
|
Claude finished @chinkan's task in 2m 57s —— View job Code Review: PR #34 — feat: self-learning + self-update features + fix telegram stream split bug
OverviewGood structure overall — the Telegram stream-split fix is correct, the OAuth implementation is solid (and the Mutex-held-across-IO bug from prior review comments has already been fixed in this PR via field-cloning before lock release). The main issues below are centered on 🔴 Critical Issues1.
Since both learning tasks are awaited inline (up to 60s each), the user sees the unformatted "in-progress" Telegram message for up to 120 seconds after the LLM finishes — the response text is there but the final formatted edit hasn't happened yet. // agent.rs ~line 276
let stream_status_tx = stream_token_tx.clone(); // clone keeps channel open
// ... agentic loop ...
// stream_token_tx consumed here — but stream_status_tx still alive:
let final_content = if let Some(tx) = stream_token_tx {
LlmClient::stream_text(content.clone(), tx).await.ok(); // tx dropped here
...
};
// 🔴 stream_status_tx still alive → channel open → Telegram finalizer blocked
let _extraction_result = tokio::time::timeout(
std::time::Duration::from_secs(60),
crate::learning::post_task_skill_extractor(...),
).await; // up to 60s
let _update_result = tokio::time::timeout(
std::time::Duration::from_secs(60),
crate::learning::update_user_model(...),
).await; // another 60s
return Ok(final_content);
// stream_status_tx finally dropped here — Telegram makes its final editFix: Add 🟡 Significant Issues2. let output = tokio::process::Command::new(check_cmd)
.args(&check_args)
.current_dir(&exp_dir)
.output()
.await // 🟡 no timeout — infinite-loop JS or hanging cargo check blocks foreverAn infinite-loop Node.js script or a slow 3. let skill_path = skills_dir.join(skill_name).join("SKILL.md");
if !skill_path.exists() {
anyhow::bail!("Skill '{}' does not exist", skill_name);
}
4. Branch name passed unsanitized to git commands ( let branch = arguments["branch"].as_str().unwrap_or("main").to_string();
// branch is used directly in:
run_git_command(project_root, &["checkout", branch]).await
run_git_command(project_root, &["pull", "origin", branch]).awaitA branch name starting with 5. let stash_result = run_git_command(project_root, &["stash", "push", "-m", "rustfox-auto-stash-before-update"]).await?;
// ... build succeeds ...
// 🟡 stash is never popped — accumulates in git reflog on repeated self-updatesAfter a successful build (or even a failed one), the stash is never restored. Repeated self-updates will pile up stash entries and lose track of what was stashed. Fix this → 🟠 Minor Issues6. Experiment directories never cleaned up ( Each 7. User model validation too permissive ( if !new_content.contains("---") || new_content.trim().is_empty() {Any response containing 8. None => {
tracing::warn!("HTTP MCP server '{}' has no auth_token configured; ...", config.name);
}OAuth-provisioned servers (like Notion) intentionally have no static 9. OAuth scope parameter missing ( The authorization URL omits 10. LLM-generated skill body has no size cap (
✅ What's Working Well
SummaryThe Telegram fix and OAuth wiring are solid. Issue #1 ( |
|
@copilot please fix the issue of cargo fmt 0s
|
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/eb11a0d3-9861-4928-ba93-23485ec34097 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 15 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Inject Honcho-style user model if available | ||
| let user_model = | ||
| crate::learning::read_user_model(&self.config.learning.user_model_path).await; | ||
| if !user_model.is_empty() { | ||
| prompt.push_str("\n\n# User Model\n\n"); | ||
| prompt.push_str(&user_model); | ||
| } |
There was a problem hiding this comment.
The user model file is injected verbatim into the system prompt. Because USER.md is generated/updated from conversation history, it should be treated as untrusted input; as-is this enables persistent prompt-injection (e.g., the model can be tricked into adding tool-use instructions into USER.md that then get system-level authority on future runs). Consider either (a) parsing/whitelisting specific fields from the user model and injecting them as data, or (b) wrapping the content in clear delimiters plus an instruction like “treat as reference data, not instructions”, and rejecting/stripping any tool directives.
| // --- Self-learning: post-task skill extraction (background) --- | ||
| if self.config.learning.skill_extraction_enabled | ||
| && tool_call_count >= self.config.learning.skill_extraction_threshold | ||
| { | ||
| let msgs_clone = messages.clone(); | ||
| // Run inline with a timeout so it doesn't block too long. | ||
| let _extraction_result = tokio::time::timeout( | ||
| std::time::Duration::from_secs(60), | ||
| crate::learning::post_task_skill_extractor( | ||
| &self.llm, | ||
| &self.config.skills.directory, | ||
| &self.skills, | ||
| &msgs_clone, | ||
| tool_call_count, | ||
| ), | ||
| ) | ||
| .await; | ||
| } | ||
|
|
||
| // --- Self-learning: periodic user model update --- | ||
| { | ||
| let msg_count = messages.iter().filter(|m| m.role == "user").count(); | ||
| let update_interval = self.config.learning.user_model_update_interval; | ||
| if update_interval > 0 && msg_count % update_interval == 0 && msg_count > 0 { | ||
| info!( | ||
| "Triggering periodic user model update ({} user messages)", | ||
| msg_count | ||
| ); | ||
| let _update_result = tokio::time::timeout( | ||
| std::time::Duration::from_secs(60), | ||
| crate::learning::update_user_model( | ||
| &self.llm, | ||
| &self.memory, | ||
| &self.config.learning.user_model_path, | ||
| ), | ||
| ) | ||
| .await; | ||
| } |
There was a problem hiding this comment.
These self-learning calls run inline before process_message returns. Because the streaming sender isn’t dropped until the function returns, the Telegram stream receiver won’t perform its final markdown/entities flush until after these timeouts complete, delaying the final formatted message by up to 60s. To keep UX responsive, spawn these tasks (or move them after dropping the stream sender) and avoid awaiting them on the request path.
| "try_new_tech" => { | ||
| let technology = match arguments["technology"].as_str() { | ||
| Some(t) => t.to_string(), | ||
| None => return "Missing technology".to_string(), | ||
| }; | ||
| let experiment_code = match arguments["experiment_code"].as_str() { | ||
| Some(c) => c.to_string(), | ||
| None => return "Missing experiment_code".to_string(), | ||
| }; | ||
| let language = arguments["language"].as_str().unwrap_or("rust").to_string(); | ||
|
|
||
| let sandbox = &self.config.sandbox.allowed_directory; | ||
| let exp_id = uuid::Uuid::new_v4().to_string(); | ||
| let exp_dir = sandbox.join("experiments").join(&exp_id); | ||
|
|
||
| if let Err(e) = tokio::fs::create_dir_all(&exp_dir).await { | ||
| return format!("Failed to create experiment dir: {}", e); | ||
| } | ||
|
|
||
| let (filename, check_cmd, check_args) = match language.as_str() { | ||
| "javascript" => ("experiment.js", "node", vec!["experiment.js".to_string()]), | ||
| _ => { | ||
| // Rust: create a minimal Cargo project structure | ||
| let cargo_toml = "[package]\nname = \"experiment\"\nversion = \"0.1.0\"\nedition = \"2021\"\n".to_string(); | ||
| let src_dir = exp_dir.join("src"); | ||
| if let Err(e) = tokio::fs::create_dir_all(&src_dir).await { | ||
| return format!("Failed to create src dir: {}", e); | ||
| } | ||
| if let Err(e) = | ||
| tokio::fs::write(exp_dir.join("Cargo.toml"), cargo_toml).await | ||
| { | ||
| return format!("Failed to write Cargo.toml: {}", e); | ||
| } | ||
| if let Err(e) = | ||
| tokio::fs::write(src_dir.join("main.rs"), &experiment_code).await | ||
| { | ||
| return format!("Failed to write main.rs: {}", e); | ||
| } | ||
| ("src/main.rs", "cargo", vec!["check".to_string()]) | ||
| } | ||
| }; | ||
|
|
||
| // Write experiment code for JS (Rust already written above) | ||
| if language == "javascript" { | ||
| if let Err(e) = tokio::fs::write(exp_dir.join(filename), &experiment_code).await | ||
| { | ||
| return format!("Failed to write experiment file: {}", e); | ||
| } | ||
| } | ||
|
|
||
| info!( | ||
| "Running experiment '{}' in {}", | ||
| technology, | ||
| exp_dir.display() | ||
| ); | ||
|
|
||
| let output = match tokio::process::Command::new(check_cmd) | ||
| .args(&check_args) | ||
| .current_dir(&exp_dir) | ||
| .output() | ||
| .await | ||
| { | ||
| Ok(o) => o, | ||
| Err(e) => return format!("Failed to run experiment: {}", e), | ||
| }; | ||
|
|
||
| let stdout = String::from_utf8_lossy(&output.stdout); | ||
| let stderr = String::from_utf8_lossy(&output.stderr); | ||
| let exit_code = output.status.code().unwrap_or(-1); | ||
| let success = output.status.success(); | ||
|
|
||
| let mut result = format!("Experiment: {}\nLanguage: {}\n", technology, language); | ||
| if !stdout.is_empty() { | ||
| result.push_str(&format!("STDOUT:\n{}\n", stdout)); |
There was a problem hiding this comment.
try_new_tech executes arbitrary Rust/JS code via cargo/node. There’s no access control here (it’s available to the main LLM toolset), so a prompt-injection can cause code execution in the sandbox and potentially exfiltrate data or consume resources. Gate this tool behind an explicit config flag and/or restrict to trusted user_ids, and consider adding runtime limits (CPU/time/memory) and disabling network access for the spawned process if possible.
| "self_update_to_branch" => { | ||
| let branch = arguments["branch"].as_str().unwrap_or("main").to_string(); | ||
|
|
||
| info!("Self-update requested: branch '{}'", branch); | ||
|
|
||
| // Determine project root from the current executable's location. | ||
| let project_root = match std::env::current_exe() { | ||
| Ok(exe) => { | ||
| // Navigate up from target/release/rustfox or target/debug/rustfox | ||
| let mut root = exe.clone(); | ||
| let mut depth = 0; | ||
| const MAX_DEPTH: usize = 10; | ||
| // Try to find Cargo.toml by walking up | ||
| loop { | ||
| if root.join("Cargo.toml").exists() { | ||
| break; | ||
| } | ||
| depth += 1; | ||
| if depth > MAX_DEPTH || !root.pop() { | ||
| // Fallback to current directory | ||
| root = std::env::current_dir() | ||
| .unwrap_or_else(|_| std::path::PathBuf::from(".")); | ||
| break; | ||
| } | ||
| } | ||
| root | ||
| } | ||
| Err(_) => { | ||
| std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) | ||
| } | ||
| }; | ||
|
|
||
| match crate::learning::self_update(&branch, &project_root).await { | ||
| Ok(log) => log, | ||
| Err(e) => format!("Self-update failed: {:#}", e), | ||
| } | ||
| } |
There was a problem hiding this comment.
self_update_to_branch runs git operations and a release build in the project root, which is outside the sandbox and can modify the running deployment. Since this is callable as a tool, it should be treated as highly privileged: restrict it to admin/trusted users, validate branch names (allowlist or safe pattern), and consider a config toggle to disable it by default to reduce blast radius from prompt-injection.
| "patch_skill" => { | ||
| let skill_name = match arguments["skill_name"].as_str() { | ||
| Some(n) => n.to_string(), | ||
| None => return "Missing skill_name".to_string(), | ||
| }; | ||
| let patch_content = match arguments["patch_content"].as_str() { | ||
| Some(c) => c.to_string(), | ||
| None => return "Missing patch_content".to_string(), | ||
| }; | ||
|
|
||
| match crate::learning::self_patch_skill( | ||
| &self.config.skills.directory, | ||
| &skill_name, | ||
| &patch_content, | ||
| &self.skills, | ||
| ) | ||
| .await | ||
| { | ||
| Ok(msg) => msg, | ||
| Err(e) => format!("Patch failed: {:#}", e), | ||
| } | ||
| } |
There was a problem hiding this comment.
patch_skill allows the model to persistently modify SKILL.md files (and then hot-reloads them). Without strong access control, this is an escalation path for prompt-injection to create durable malicious behavior. Consider restricting to admin users / explicit enablement, and validating that the patch keeps required frontmatter fields and doesn’t introduce disallowed tool declarations.
| let analysis_prompt = format!( | ||
| "You are a skill-extraction engine for an AI assistant called RustFox.\n\ | ||
| \n\ | ||
| Analyze the following conversation transcript and decide if it contains a \ | ||
| **reusable, multi-step workflow** that should be saved as a new skill.\n\ | ||
| \n\ | ||
| Rules:\n\ | ||
| - Only create a skill if the pattern would help the assistant handle SIMILAR \ | ||
| future requests more efficiently.\n\ | ||
| - Do NOT create skills for: one-off questions, trivial lookups, simple math, \ | ||
| greetings, or tasks that are too specific to generalize.\n\ | ||
| - The skill name must be lowercase letters, numbers, and hyphens only (1-64 chars).\n\ | ||
| - These skills already exist (do NOT duplicate): {existing}\n\ | ||
| \n\ | ||
| If a skill SHOULD be created, respond with EXACTLY this format (no extra text):\n\ | ||
| ```\n\ | ||
| SKILL_NAME: <name>\n\ | ||
| SKILL_DESCRIPTION: <one-line description>\n\ | ||
| SKILL_TAGS: <comma-separated tags>\n\ | ||
| SKILL_BODY:\n\ | ||
| <full markdown body of the skill>\n\ | ||
| ```\n\ | ||
| \n\ | ||
| If NO skill should be created, respond with exactly: NO_SKILL\n\ | ||
| \n\ | ||
| TRANSCRIPT:\n{transcript}", | ||
| existing = existing_names.join(", "), | ||
| transcript = transcript, | ||
| ); | ||
|
|
||
| let analysis_messages = vec![ChatMessage { | ||
| role: "user".to_string(), | ||
| content: Some(analysis_prompt), | ||
| tool_calls: None, | ||
| tool_call_id: None, | ||
| }]; | ||
|
|
||
| let response = llm.chat(&analysis_messages, &[]).await?; | ||
| let content = response.content.unwrap_or_default(); | ||
|
|
||
| if content.trim() == "NO_SKILL" || !content.contains("SKILL_NAME:") { | ||
| return Ok(None); | ||
| } | ||
|
|
||
| // Parse the structured response. | ||
| let name = extract_line_value(&content, "SKILL_NAME:") | ||
| .context("Missing SKILL_NAME in LLM response")?; | ||
| let description = extract_line_value(&content, "SKILL_DESCRIPTION:") | ||
| .unwrap_or_else(|| "Auto-generated skill".to_string()); | ||
| let tags_raw = extract_line_value(&content, "SKILL_TAGS:").unwrap_or_default(); | ||
| let tags: Vec<String> = tags_raw | ||
| .split(',') | ||
| .map(|t| t.trim().to_string()) | ||
| .filter(|t| !t.is_empty()) | ||
| .collect(); | ||
|
|
||
| let body = extract_body_after(&content, "SKILL_BODY:") | ||
| .unwrap_or_else(|| "# Auto-generated skill\n\nNo instructions extracted.".to_string()); | ||
|
|
There was a problem hiding this comment.
The prompt asks the model to wrap its response in triple backticks, but the parser doesn’t strip code fences. As a result, the extracted SKILL_BODY (and possibly other fields) will often include leading/trailing lines and get written into SKILL.md. Consider parsing only the content inside the first fenced block (or removing delimiters before extract_line_value/extract_body_after) and validating the final markdown doesn’t contain the wrapper.
| let msg_count = messages.iter().filter(|m| m.role == "user").count(); | ||
| let update_interval = self.config.learning.user_model_update_interval; | ||
| if update_interval > 0 && msg_count % update_interval == 0 && msg_count > 0 { | ||
| info!( | ||
| "Triggering periodic user model update ({} user messages)", | ||
| msg_count | ||
| ); | ||
| let _update_result = tokio::time::timeout( |
There was a problem hiding this comment.
The periodic user-model update trigger uses messages.iter().filter(|m| m.role == "user").count(), but messages is loaded with max_raw_messages so it’s a sliding window, not the total conversation length. Once older messages are truncated, msg_count % update_interval == 0 can fire repeatedly/unpredictably, causing more updates than intended. Consider basing this on a persisted counter (e.g., total user-message count for the conversation) or on time since last update stored in memory/knowledge.
|
|
||
| // Basic validation: must contain frontmatter. | ||
| if !new_content.contains("---") || new_content.trim().is_empty() { |
There was a problem hiding this comment.
Validation of the updated USER.md is very weak: it only checks new_content.contains("---"), so content that merely includes --- anywhere (or malformed frontmatter) will be accepted and written. Since USER.md is later injected into the system prompt, consider stricter validation (e.g., must start with --- and contain a second --- delimiter, and optionally verify required frontmatter keys are present) before persisting.
| // Basic validation: must contain frontmatter. | |
| if !new_content.contains("---") || new_content.trim().is_empty() { | |
| let trimmed_content = new_content.trim(); | |
| // Validate that the file starts with YAML frontmatter, has a closing | |
| // delimiter, and includes the required frontmatter keys. | |
| let mut lines = trimmed_content.lines(); | |
| let has_valid_frontmatter = if trimmed_content.is_empty() { | |
| false | |
| } else if matches!(lines.next(), Some(first_line) if first_line.trim() == "---") { | |
| let mut frontmatter_lines = Vec::new(); | |
| let mut found_closing_delimiter = false; | |
| for line in lines.by_ref() { | |
| if line.trim() == "---" { | |
| found_closing_delimiter = true; | |
| break; | |
| } | |
| frontmatter_lines.push(line); | |
| } | |
| if found_closing_delimiter { | |
| let frontmatter = frontmatter_lines.join("\n"); | |
| ["name:", "description:", "tags:"] | |
| .iter() | |
| .all(|required_key| { | |
| frontmatter | |
| .lines() | |
| .any(|line| line.trim_start().starts_with(required_key)) | |
| }) | |
| } else { | |
| false | |
| } | |
| } else { | |
| false | |
| }; | |
| if !has_valid_frontmatter { |
|
@copilot apply changes based on the comments in this thread |
- Wrap USER.md in <user_model> delimiters + instructions to prevent
prompt-injection escalation
- Spawn skill extraction and user model update as background tokio tasks
(via self_weak.upgrade()) so they no longer block the user response
- Strip code fences from LLM response before parsing extracted skill fields
- YAML-single-quote description and tags in generated SKILL.md frontmatter
- Remove hard-coded MIN_TOOL_CALLS_FOR_EXTRACTION=5 guard; rely solely on
configurable skill_extraction_threshold from caller
- Add path-traversal validation + canonicalize check to self_patch_skill
- Replace weak `contains('---')` USER.md check with has_valid_frontmatter()
(must start with --- and contain closing ---)
- Add experiment directory cleanup after try_new_tech execution
- Add git branch name validation to self_update_to_branch tool
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/f58eb4c3-21f9-4e11-91e4-8c352b8b04d4
Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/f58eb4c3-21f9-4e11-91e4-8c352b8b04d4 Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Applied all 11 changes from the review thread in commits
All 205 tests pass, clippy clean, |
|
@copilot use superpower, research deply on web, reference https://developers.notion.com/guides/mcp/build-mcp-client and https://developers.notion.com/guides/mcp/mcp-security-best-practices to add refresh token and auto refresh the access token to config file to make mcp notion connect long live. please research in web deeply on best practice for implement this |
…ns (Notion) Agent-Logs-Url: https://github.com/chinkan/RustFox/sessions/8cdd53e9-7d0e-4558-8dd8-2106aa2104aa Co-authored-by: chinkan <16433287+chinkan@users.noreply.github.com>
Implemented in Config fields (
Setup wizard (
Auto-refresh (
Lifecycle (
Docs — Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|

Adds self-learning and self-update capabilities to the RustFox agent, expands the setup wizard with an OAuth flow (Notion-focused) for HTTP MCP servers with long-lived token support via refresh tokens, and fixes Telegram streaming message splitting behavior.
Changes Made
Self-Learning & Self-Update
learningmodule with skill extraction, skill patching, user model maintenance (USER.md), and git-based self-updatetokio::spawntasks — no longer block the user responseUSER.mdwrapped in<user_model>delimiters to prevent prompt-injection escalationdescriptionandtagsin generatedSKILL.mdfrontmatter to handle special charactersself_patch_skillvalidatesskill_nameagainst path traversal attacks (canonicalises path, ensures it stays insideskills_dir)USER.mdvalidation requires valid YAML frontmatter (starts with---, has matching closing---)fs::remove_dir_allaftertry_new_techcompletesself_update_to_branch(allowlist check prevents-flags,.., control chars, etc.)MIN_TOOL_CALLS_FOR_EXTRACTION=5guard removed; the configurableskill_extraction_thresholdis now the sole gateOAuth Setup Wizard
GET /api/oauth/start(DCR + PKCE),GET /oauth/callback(token exchange),GET /api/oauth/token(polling)AppStateextended withoauth_sessionsandhttp_client__NOTION_OAUTH_BUTTON__andconnectOAuth()JS function for popup + polling + auto-fillOAuth Refresh Tokens (Long-Lived MCP Connections)
McpServerConfignow acceptsrefresh_token,token_expires_at,token_endpoint,oauth_client_id, andoauth_client_secretfor OAuth-based HTTP MCP serversoauth_callbackstoresrefresh_tokenandexpires_infrom the token exchange responseoauth_token_pollreturns all OAuth credentials (refresh_token,expires_in,token_endpoint,client_id,client_secret)mcp.rsgainstoken_needs_refresh(),refresh_oauth_token(),update_config_tokens(), andrefresh_expiring_tokens()helpersconfig.tomlwarn!on missingauth_tokendowngraded todebugconfig.example.tomlNotion example updated to document all OAuth token fieldsTelegram Stream Split Fix
current_msg_idis reset toNoneso the next tokens open a fresh message — eliminates the "message shrinking" artifactTesting
cargo clippy -- -D warningsclean,cargo fmtclean