Skip to content

Commit ab9c05d

Browse files
committed
Version 1.0.4 is released.
1 parent 49bb518 commit ab9c05d

4 files changed

Lines changed: 175 additions & 8 deletions

File tree

lib/core/llm/anthropic_client.dart

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -335,10 +335,12 @@ class AnthropicClient implements LlmClient {
335335
}
336336

337337
/// 解析 Anthropic SSE:content_block_delta(text) → ContentToken;
338-
/// tool_use block 累积 input_json_delta;message_stop → StreamComplete。
338+
/// thinking block → ReasoningToken;tool_use block 累积 input_json_delta;
339+
/// message_stop → StreamComplete。
339340
Stream<StreamEvent> _parseSse(Stream<List<int>> rawStream) async* {
340341
String buffer = '';
341342
final contentBuf = StringBuffer();
343+
final thinkingBuf = StringBuffer(); // 扩展思考内容
342344
String finishReason = 'stop';
343345

344346
// index → 正在累积的 tool_use block
@@ -373,18 +375,28 @@ class AnthropicClient implements LlmClient {
373375
name: (block['name'] ?? '').toString(),
374376
);
375377
}
378+
// 注意:thinking block 在 start 时没有内容,在 delta 中传输
376379
break;
377380
case 'content_block_delta':
378381
final idx = json['index'] as int? ?? 0;
379382
final delta = json['delta'] as Map<String, dynamic>?;
380383
if (delta == null) break;
381-
if (delta['type'] == 'text_delta') {
384+
385+
final deltaType = delta['type'] as String?;
386+
if (deltaType == 'text_delta') {
382387
final text = (delta['text'] ?? '').toString();
383388
if (text.isNotEmpty) {
384389
contentBuf.write(text);
385390
yield ContentToken(text);
386391
}
387-
} else if (delta['type'] == 'input_json_delta') {
392+
} else if (deltaType == 'thinking_delta') {
393+
// 扩展思考内容
394+
final text = (delta['thinking'] ?? '').toString();
395+
if (text.isNotEmpty) {
396+
thinkingBuf.write(text);
397+
yield ReasoningToken(text);
398+
}
399+
} else if (deltaType == 'input_json_delta') {
388400
toolBlocks[idx]?.argsBuf.write(delta['partial_json'] ?? '');
389401
}
390402
break;
@@ -395,7 +407,7 @@ class AnthropicClient implements LlmClient {
395407
}
396408
break;
397409
case 'message_stop':
398-
yield _complete(contentBuf, toolBlocks, finishReason);
410+
yield _complete(contentBuf, thinkingBuf, toolBlocks, finishReason);
399411
return;
400412
case 'error':
401413
final msg = json['error']?['message'] ?? 'Anthropic stream error';
@@ -405,7 +417,12 @@ class AnthropicClient implements LlmClient {
405417
}
406418

407419
// 流未显式 message_stop:返回截断标记,上层不得当作正常完成。
408-
final fallback = _complete(contentBuf, toolBlocks, finishReason);
420+
final fallback = _complete(
421+
contentBuf,
422+
thinkingBuf,
423+
toolBlocks,
424+
finishReason,
425+
);
409426
yield StreamComplete(
410427
content: fallback.content,
411428
reasoningContent: fallback.reasoningContent,
@@ -417,6 +434,7 @@ class AnthropicClient implements LlmClient {
417434

418435
StreamComplete _complete(
419436
StringBuffer contentBuf,
437+
StringBuffer thinkingBuf,
420438
Map<int, _AnthropicToolBlock> toolBlocks,
421439
String finishReason,
422440
) {
@@ -426,6 +444,7 @@ class AnthropicClient implements LlmClient {
426444
}
427445
return StreamComplete(
428446
content: contentBuf.isEmpty ? null : contentBuf.toString(),
447+
reasoningContent: thinkingBuf.isEmpty ? null : thinkingBuf.toString(),
429448
toolCalls: calls,
430449
finishReason: finishReason,
431450
);

lib/features/chat/chat_provider.dart

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1012,9 +1012,17 @@ class ChatNotifier extends StateNotifier<ChatState> {
10121012
);
10131013
// 如果 streamingText 含有 DSML 标记(模型以文本形式输出的工具调用),
10141014
// 说明这些内容已被解析为 tool_call,清除残留的标记文本
1015-
final cleanedStream = state.streamingText.contains('DSML')
1015+
var cleanedStream = state.streamingText.contains('DSML')
10161016
? ''
10171017
: state.streamingText;
1018+
1019+
// 自动补全未闭合的 <thinking> 标签
1020+
// 某些模型在思考模式下调用工具时,会输出 <thinking> 但忘记闭合标签
1021+
if (cleanedStream.contains('<thinking>') &&
1022+
!cleanedStream.contains('</thinking>')) {
1023+
cleanedStream += '\n</thinking>';
1024+
}
1025+
10181026
state = state.copyWith(
10191027
streamingText: cleanedStream,
10201028
activeToolCalls: [...state.activeToolCalls, toolCall],

lib/features/multi_agent/providers/multi_agent_provider.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -628,10 +628,11 @@ class MultiAgentNotifier extends StateNotifier<MultiAgentState> {
628628
);
629629

630630
// 创建 Executor
631-
// 注意: 多智能体全自动执行 (无权限中间件确认),故保持目录边界沙箱
632-
// (allowOutsideRoot 默认 false),避免子智能体无确认地越界写/删
631+
// 多智能体协作模式下,解除目录边界限制以支持跨文件访问。
632+
// 由于是 auto 模式(无权限中间件),所有操作自动执行
633633
final executor = CombinedExecutor(
634634
projectRoot: state.workingDirectory ?? '',
635+
allowOutsideRoot: true, // 允许跨工作目录访问
635636
mcpClients: {},
636637
mcpToolsCache: {},
637638
);

test/executor_cross_file_test.dart

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import 'dart:io';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:path/path.dart' as p;
4+
import 'package:remind_ai/core/memory/project_config.dart';
5+
import 'package:remind_ai/core/toolshell/executor.dart';
6+
7+
/// 测试 auto 模式 + allowOutsideRoot 的跨文件访问
8+
void main() {
9+
group('Executor 跨文件访问测试', () {
10+
late Directory tempDir;
11+
late Directory projectDir;
12+
late Directory outsideDir;
13+
late String testFilePath;
14+
15+
setUp(() async {
16+
// 创建临时目录结构
17+
tempDir = await Directory.systemTemp.createTemp('executor_test_');
18+
projectDir = Directory(p.join(tempDir.path, 'project'));
19+
outsideDir = Directory(p.join(tempDir.path, 'outside'));
20+
await projectDir.create();
21+
await outsideDir.create();
22+
23+
// 在项目外创建测试文件
24+
testFilePath = p.join(outsideDir.path, 'test.txt');
25+
await File(testFilePath).writeAsString('外部文件内容');
26+
});
27+
28+
tearDown(() async {
29+
await tempDir.delete(recursive: true);
30+
});
31+
32+
test('allowOutsideRoot=false (默认) 应该拒绝跨文件访问', () async {
33+
final executor = Executor(
34+
projectRoot: projectDir.path,
35+
permissionMode: PermissionMode.auto,
36+
allowOutsideRoot: false, // 默认值
37+
);
38+
39+
final result = await executor.run('toolshell_read', {
40+
'path': testFilePath,
41+
});
42+
43+
// 应该返回错误
44+
expect(result, contains('路径越界'));
45+
});
46+
47+
test('allowOutsideRoot=true 应该允许跨文件访问(绝对路径)', () async {
48+
final executor = Executor(
49+
projectRoot: projectDir.path,
50+
permissionMode: PermissionMode.auto,
51+
allowOutsideRoot: true, // 关键设置
52+
);
53+
54+
final result = await executor.run('toolshell_read', {
55+
'path': testFilePath,
56+
});
57+
58+
print('读取结果: $result');
59+
60+
// 应该成功读取
61+
expect(result, contains('外部文件内容'));
62+
expect(result, contains('status'));
63+
expect(result, contains('success'));
64+
});
65+
66+
test('allowOutsideRoot=true 相对路径仍然相对 projectRoot', () async {
67+
final executor = Executor(
68+
projectRoot: projectDir.path,
69+
permissionMode: PermissionMode.auto,
70+
allowOutsideRoot: true,
71+
);
72+
73+
// 在项目内创建文件
74+
final insideFile = File(p.join(projectDir.path, 'inside.txt'));
75+
await insideFile.writeAsString('项目内文件');
76+
77+
// 使用相对路径
78+
final result = await executor.run('toolshell_read', {
79+
'path': 'inside.txt', // 相对路径
80+
});
81+
82+
print('相对路径读取结果: $result');
83+
84+
// 应该成功读取
85+
expect(result, contains('项目内文件'));
86+
});
87+
88+
test('auto 模式 + allowOutsideRoot=true 应该允许写入外部文件', () async {
89+
final executor = Executor(
90+
projectRoot: projectDir.path,
91+
permissionMode: PermissionMode.auto,
92+
allowOutsideRoot: true,
93+
);
94+
95+
final outsideWritePath = p.join(outsideDir.path, 'write_test.txt');
96+
97+
final result = await executor.run('toolshell_write', {
98+
'path': outsideWritePath,
99+
'content': '写入外部内容',
100+
'mode': 'create',
101+
});
102+
103+
print('写入结果: $result');
104+
105+
// 应该成功写入
106+
expect(result, contains('success'));
107+
108+
// 验证文件确实被创建
109+
expect(await File(outsideWritePath).exists(), isTrue);
110+
expect(await File(outsideWritePath).readAsString(), equals('写入外部内容'));
111+
});
112+
113+
test('normal 模式即使 allowOutsideRoot=true 也需要权限确认', () async {
114+
var permissionRequested = false;
115+
116+
final executor = Executor(
117+
projectRoot: projectDir.path,
118+
permissionMode: PermissionMode.normal, // 注意这里是 normal
119+
allowOutsideRoot: true,
120+
onPermissionRequest: (tool, args) async {
121+
permissionRequested = true;
122+
return false; // 拒绝
123+
},
124+
);
125+
126+
final result = await executor.run('toolshell_write', {
127+
'path': p.join(outsideDir.path, 'blocked.txt'),
128+
'content': '应该被拒绝',
129+
'mode': 'create',
130+
});
131+
132+
// 应该请求了权限
133+
expect(permissionRequested, isTrue);
134+
135+
// 应该被拒绝
136+
expect(result, contains('PERMISSION_DENIED'));
137+
});
138+
});
139+
}

0 commit comments

Comments
 (0)