Skip to content
Open
153 changes: 147 additions & 6 deletions apps/remix-ide/src/app/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const profile = {
name: 'editor',
description: 'service - editor',
version: packageJson.version,
methods: ['highlight', 'discardHighlight', 'clearAnnotations', 'addLineText', 'discardLineTexts', 'addAnnotation', 'gotoLine', 'revealRange', 'getCursorPosition', 'open', 'addModel','addErrorMarker', 'clearErrorMarkers', 'getText', 'getPositionAt', 'openReadOnly', 'showCustomDiff', 'hasUnacceptedChanges', 'clearAllBreakpoints'],
methods: ['highlight', 'discardHighlight', 'clearAnnotations', 'addLineText', 'discardLineTexts', 'addAnnotation', 'gotoLine', 'revealRange', 'getCursorPosition', 'open', 'addModel','addErrorMarker', 'clearErrorMarkers', 'getText', 'getPositionAt', 'openReadOnly', 'showCustomDiff', 'hasUnacceptedChanges', 'clearAllBreakpoints', 'acceptDiff', 'discardDiff', 'getDiffSessions', 'setActiveDiff', 'closeDiffSession'],
}

export default class Editor extends Plugin {
Expand All @@ -37,6 +37,11 @@ export default class Editor extends Plugin {
this.previousInput = ''
this.saveTimeout = null
this.emptySession = null

// Multiple diff sessions support
this.diffSessions = {} // Store multiple diff sessions: { diffId: { originalPath, modifiedPath, originalContent, modifiedContent, path } }
this.activeDiffId = null // Currently active diff session
this.diffCounter = 0 // Counter for generating unique diff IDs
this.modes = {
sol: 'sol',
yul: 'sol',
Expand Down Expand Up @@ -512,14 +517,133 @@ export default class Editor extends Plugin {
return this.api.findMatches(this.currentFile, string)
}

_simpleHash(str) {
let hash = 0;
if (str.length === 0) return hash.toString();
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString();
}

async showCustomDiff (file, content) {
return this.api.showCustomDiff(file, content)
const source = this.getText(file)
console.log('Showing diff for', file, { source, content })
this.openDiff({
hashOriginal: this._simpleHash(source),
hashModified: this._simpleHash(content),
readonly: true,
path: file,
modified: content,
original: source,
type: "modified",
})
// return this.api.showCustomDiff(file, content)
}

hasUnacceptedChanges () {
return this.api.hasUnacceptedChanges()
}

setIsDiff (isDiff, currentDiffFile = null, hashedPathModified = null) {
this.isDiff = isDiff
this.currentDiffFile = currentDiffFile
this.hashedPathModified = hashedPathModified
}

createDiffSession (originalPath, modifiedPath, originalContent, modifiedContent, filePath) {
const diffId = `diff_${++this.diffCounter}`
this.diffSessions[diffId] = {
id: diffId,
originalPath,
modifiedPath,
originalContent,
modifiedContent,
filePath,
createdAt: Date.now()
}
return diffId
}

setActiveDiff (diffId) {
if (this.diffSessions[diffId]) {
this.activeDiffId = diffId
const session = this.diffSessions[diffId]
this.setIsDiff(true, session.originalPath, session.modifiedPath)
return true
}
return false
}

closeDiffSession (diffId) {
if (this.diffSessions[diffId]) {
const session = this.diffSessions[diffId]
// Clean up sessions
if (this.sessions[session.originalPath]) {
delete this.sessions[session.originalPath]
}
if (this.sessions[session.modifiedPath]) {
delete this.sessions[session.modifiedPath]
}
delete this.diffSessions[diffId]

// If this was the active diff, switch to another or close diff view
if (this.activeDiffId === diffId) {
const remainingDiffs = Object.keys(this.diffSessions)
if (remainingDiffs.length > 0) {
this.setActiveDiff(remainingDiffs[0])
} else {
this.setIsDiff(false)
this.activeDiffId = null
}
}
return true
}
return false
}

getDiffSessions () {
return Object.values(this.diffSessions)
}

acceptDiff () {
if (!this.activeDiffId || !this.diffSessions[this.activeDiffId]) {
return false
}

const diffSession = this.diffSessions[this.activeDiffId]
console.log('Accepting diff for', diffSession.filePath, { diffId: this.activeDiffId })

// Open the original file with the modified content
this.open(diffSession.filePath, diffSession.modifiedContent)
this.emit('customDiffAccepted', diffSession.filePath)

// Close this diff session
this.closeDiffSession(this.activeDiffId)

return true
}

discardDiff () {
if (!this.activeDiffId || !this.diffSessions[this.activeDiffId]) {
return false
}

const diffSession = this.diffSessions[this.activeDiffId]
console.log('Discarding diff for', diffSession.filePath, { diffId: this.activeDiffId })

// Open the original file with the original content (discarding changes)
this.open(diffSession.filePath, diffSession.originalContent)
this.emit('customDiffRejected', diffSession.filePath)

// Close this diff session
this.closeDiffSession(this.activeDiffId)

return true
}

addModel(path, content) {
this.emit('addModel', content, this._getMode(path), path, this.readOnlySessions[path])
}
Expand Down Expand Up @@ -566,7 +690,7 @@ export default class Editor extends Plugin {
- URL prepended with "browser"
- URL not prepended with the file explorer. We assume (as it is in the whole app, that this is a "browser" URL
*/
this.isDiff = false
this.setIsDiff(false)
if (!this.sessions[path]) {
this.readOnlySessions[path] = false
const session = await this._createSession(path, content, this._getMode(path))
Expand All @@ -588,19 +712,36 @@ export default class Editor extends Plugin {
const session = await this._createSession(path, content, this._getMode(path))
this.sessions[path] = session
}
this.isDiff = false
this.setIsDiff(false)
this._switchSession(path)
}

async openDiff(change) {
const openedfiles = await this.call('fileManager', 'getOpenedFiles')
if (!openedfiles[change.path] || !openedfiles) {
await this.call('fileManager', 'openFile', change.path)
await new Promise(resolve => setTimeout(resolve, 500)) // wait for file to be opened and content to be loaded in the file manager
}
const hashedPathModified = change.readonly ? change.path + change.hashModified : change.path
const hashedPathOriginal = change.path + change.hashOriginal
const session = await this._createSession(hashedPathModified, change.modified, this._getMode(change.path), change.readonly)
await this._createSession(hashedPathOriginal, change.original, this._getMode(change.path), change.readonly)
this.sessions[hashedPathModified] = session
this.currentDiffFile = hashedPathOriginal
this.isDiff = true

// Create a new diff session
const diffId = this.createDiffSession(
hashedPathOriginal,
hashedPathModified,
change.original,
change.modified,
change.path
)

// Set this as the active diff
this.setActiveDiff(diffId)
this._switchSession(hashedPathModified)

return diffId
}

/**
Expand Down
15 changes: 15 additions & 0 deletions apps/remix-ide/src/app/plugins/remixAIPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const profile = {
'addMCPServer', 'removeMCPServer', 'getMCPConnectionStatus', 'getMCPResources', 'getMCPTools', 'executeMCPTool',
'enableMCPEnhancement', 'disableMCPEnhancement', 'isMCPEnabled', 'getIMCPServers',
'enableDeepAgent', 'disableDeepAgent', 'isDeepAgentEnabled',
'respondToToolApproval',
'clearCaches', 'cancelRequest'
],
events: [
Expand Down Expand Up @@ -89,6 +90,7 @@ export class RemixAIPlugin extends Plugin {
eventEmitter.removeAllListeners('onSubagentComplete')
eventEmitter.removeAllListeners('onTaskStart')
eventEmitter.removeAllListeners('onTaskComplete')
eventEmitter.removeAllListeners('onToolApprovalRequired')

// Set up fresh listeners
eventEmitter.on('onInference', () => {
Expand Down Expand Up @@ -119,6 +121,12 @@ export class RemixAIPlugin extends Plugin {
this.emit('onTaskComplete', data)
})

// Human-in-the-loop: relay approval requests to UI
eventEmitter.on('onToolApprovalRequired', (request: any) => {

this.emit('onToolApprovalRequired', request)
})

this.deepAgentEventListenersSetup = true
console.log('[RemixAI Plugin] DeepAgent event listeners set up')
}
Expand Down Expand Up @@ -898,6 +906,13 @@ export class RemixAIPlugin extends Plugin {
return this.deepAgentEnabled
}

respondToToolApproval(response: { requestId: string; approved: boolean; modifiedArgs?: Record<string, any> }): void {

if (this.deepAgentInferencer) {
this.deepAgentInferencer.getEventEmitter().emit('onToolApprovalResponse', response)
}
}

clearCaches(){
if (this.mcpInferencer){
this.mcpInferencer.resetResourceCache()
Expand Down
1 change: 1 addition & 0 deletions libs/remix-ai-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ export * from './agents/workspaceAgent'
export * from './storage'
export * from './inferencers/deepagent'
export * from './types/deepagent'
export * from './types/humanInTheLoop'
44 changes: 23 additions & 21 deletions libs/remix-ai-core/src/inferencers/deepagent/DeepAgentInferencer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { IAIStreamResponse, ICompletions, IGeneration, IParams } from '../../typ
import { Plugin } from '@remixproject/engine'
import EventEmitter from 'events'
import { RemixFilesystemBackend } from './RemixFilesystemBackend'
import { createRemixTools } from './RemixToolAdapter'
import { createRemixTools, ToolApprovalGate } from './RemixToolAdapter'
import {
REMIX_DEEPAGENT_SYSTEM_PROMPT,
SOLIDITY_CODE_GENERATION_PROMPT,
Expand Down Expand Up @@ -96,6 +96,7 @@ export class DeepAgentInferencer implements ICompletions, IGeneration {
private filesystemBackend: RemixFilesystemBackend
private memoryBackend: DeepAgentMemoryBackend | null = null
private tools: DynamicStructuredTool[] = []
private approvalGate: ToolApprovalGate | null = null
private currentAbortController: AbortController | null = null
private fallbackInferencer: any = null
private model: BaseChatModel | null = null
Expand Down Expand Up @@ -130,10 +131,11 @@ export class DeepAgentInferencer implements ICompletions, IGeneration {
enablePlanning: config?.enablePlanning !== false
}

// Initialize filesystem backend
this.filesystemBackend = new RemixFilesystemBackend(plugin)
// Initialize filesystem backend with shared EventEmitter for approval
this.filesystemBackend = new RemixFilesystemBackend(plugin, this.event) as any

// Initialize tools (with external MCP clients if available)
// Initialize tools with approval gate
this.approvalGate = new ToolApprovalGate(plugin, this.event, 'ask_risky')
this.initializeTools(toolRegistry, mcpInferencer)
}

Expand Down Expand Up @@ -163,6 +165,8 @@ export class DeepAgentInferencer implements ICompletions, IGeneration {
await this.memoryBackend.init()
}

// LangGraph checkpointer — manages agent internal state (conversation, tool calls)
// Ref: Yann's PR #7080 (langchain_skills)
const checkpointer = new MemorySaver();

// Create DeepAgent configuration
Expand Down Expand Up @@ -233,7 +237,7 @@ export class DeepAgentInferencer implements ICompletions, IGeneration {
*/
private async initializeTools(toolRegistry: ToolRegistry, mcpInferencer?: any): Promise<void> {
try {
this.tools = await createRemixTools(this.plugin, toolRegistry, mcpInferencer)
this.tools = await createRemixTools(this.plugin, toolRegistry, mcpInferencer, this.approvalGate)
console.log(`[DeepAgentInferencer] Initialized ${this.tools.length} tools`)
} catch (error) {
console.warn('[DeepAgentInferencer] Failed to initialize tools:', error)
Expand Down Expand Up @@ -350,20 +354,9 @@ export class DeepAgentInferencer implements ICompletions, IGeneration {
DeepAgentErrorType.INITIALIZATION_FAILED
)
}
const chatHistory = buildChatPrompt()
let messages = []

if (chatHistory.length > 0) {
messages = [
...chatHistory,
{ role: 'user', content: context ? `Context:\n${context}\n\nQuestion: ${prompt}` : prompt }
]
} else {
messages = [
{ role: 'system', content: REMIX_DEEPAGENT_SYSTEM_PROMPT },
{ role: 'user', content: context ? `Context:\n${context}\n\nQuestion: ${prompt}` : prompt }
]
}
const messages = [
{ role: 'user', content: context ? `Context:\n${context}\n\nQuestion: ${prompt}` : prompt }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hsy822 previous context is not inside the llm. How is that managed internally by lang chain graph?

]
console.log('[DeepAgentInferencer] Running answer with messages:')
const responsePromise = this.runAgent(messages, params)

Expand Down Expand Up @@ -490,6 +483,7 @@ export class DeepAgentInferencer implements ICompletions, IGeneration {
},
{
version: 'v2',
// Each request gets a unique thread_id for the LangGraph checkpointer.
configurable: {
thread_id: `remix-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
},
Expand Down Expand Up @@ -599,7 +593,6 @@ export class DeepAgentInferencer implements ICompletions, IGeneration {
console.log('[DeepAgentInferencer] Tool call started:', toolName, toolInput)
this.event.emit('onToolCall', { toolName, toolInput, status: 'start' })
} else if (eventType === 'on_tool_end') {
// Tool execution completed
const toolName = event.name
const toolOutput = event.data?.output
console.log('[DeepAgentInferencer] Tool call ended:', toolName)
Expand All @@ -615,7 +608,12 @@ export class DeepAgentInferencer implements ICompletions, IGeneration {
fullResponse = finalMessageFromChain
}

console.log('[DeepAgentInferencer] Stream complete, full response length:', fullResponse.length)
// Flush any pending edit batches — this triggers the HITL modal immediately
// after the agent finishes, so the user sees the combined diff right away

await (this.filesystemBackend as any).flushAllPendingBatches()

console.log('[DeepAgentInferencer] Full response length:', fullResponse.length)
return fullResponse
} catch (error: any) {
if (error?.name === 'AbortError' || this.currentAbortController?.signal.aborted) {
Expand Down Expand Up @@ -694,6 +692,10 @@ export class DeepAgentInferencer implements ICompletions, IGeneration {
if (this.memoryBackend) {
this.memoryBackend.close()
}
if (this.approvalGate) {
this.approvalGate.dispose()
this.approvalGate = null
}
this.agent = null
this.model = null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,13 @@ task(description="Code Reviewer: Review ERC20Token.sol for code quality, documen
- Before deployment to mainnet
- User says "review everything"

# File Operations Guidelines
# File Operations Guidelines — MANDATORY

- Always read a file before editing it
**CRITICAL RULE: You MUST use tools for ALL file operations. NEVER pretend or claim to have created, edited, or modified a file without actually calling the appropriate tool (write_file, edit, etc.). If a tool call fails or is rejected, report the failure honestly. Do NOT generate file content in your text response as a substitute for actually writing the file.**

- ALWAYS use write_file tool to create new files — never just describe what the file would contain
- ALWAYS use edit tool to modify existing files — never just show the changes in text
- ALWAYS read a file before editing it
- When writing Solidity files, use .sol extension
- Place contracts in appropriate directories (contracts/, scripts/, tests/)
- Preserve existing code structure and formatting
Expand Down
Loading
Loading