commit 09080ca7f67c07b5b9227de78ca11c6d6018c13f Author: toki Date: Sun May 31 17:55:17 2026 +0900 Scaffold agent shell package diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d93fef9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.dart_tool/ +.packages +build/ +coverage/ +pubspec.lock + +# IDE/local +.idea/ +.vscode/ +*.iml + +# Generated by local experiments +.tmp/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..67095ab --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# agent-shell 프로젝트 규칙 + +## 응답 언어 + +- 기본 응답은 한국어로 한다. +- 코드, 식별자, 명령어, 에러 메시지는 원문 표기를 유지한다. + +## 프로젝트 개요 + +`agent-shell`은 제품 앱이 아니라 Flutter 앱들이 공통으로 가져다 쓰는 agent interaction package다. +채팅 메시지, tool call 표시, 승인 UI, capability registry, host integration boundary를 제공하되 IOP, NomadCode, OTO 같은 제품 도메인을 직접 알지 않는다. + +## 작업 규칙 + +- `lib/` 안에는 `MaterialApp`, `runApp`, Firebase/SystemChrome 같은 application bootstrap을 넣지 않는다. +- 제품별 의존성(`iop_*`, `nomadcode_*`)은 이 패키지에 import하지 않는다. +- 백엔드 transport, 인증, endpoint, theme, navigation은 host 앱이 주입한다. +- Agent capability는 interface와 event/command model만 제공하고, 제품별 capability pack은 각 제품 repo나 optional composition package가 소유한다. +- 새 public API를 추가하면 `lib/agent_shell.dart` export와 README 예시를 함께 확인한다. +- UI 위젯은 host layout에 embedded될 수 있게 fixed app chrome을 전제하지 않는다. +- 테스트는 변경 범위에 맞춰 `flutter test`를 우선 실행한다. + +## 진입 파일 + +- `README.md` +- `pubspec.yaml` +- `lib/agent_shell.dart` +- `agent-ops/rules/project/rules.md` diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd95aec --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# agent-shell + +`agent-shell` is a Flutter package for embeddable agent interaction surfaces. +It is not a product app. It provides the shared shell that IOP, NomadCode, and future host apps can compose with their own capability packs. + +## Purpose + +- Render agent messages, tool calls, and approval prompts. +- Provide capability and event interfaces that host apps can wire to their own backends. +- Stay product-agnostic: no IOP, NomadCode, WebView, workflow, or Control Plane dependency lives here. +- Fit into different workbench layouts, including NomadCode's right rail and IOP's left rail. + +## Package Boundary + +```text +agent_shell + AgentShell widget + AgentMessage / AgentToolCall models + AgentCapability / AgentCommand / AgentEvent interfaces + Tool call and approval UI primitives + +iop_console_flutter + IOP-specific capability pack + IOP Control Plane / Edge / Node integration + +nomadcode_app + NomadCode workbench shell + NomadCode WebView/workflow capability pack + optional IOP composition package +``` + +## Example + +```dart +AgentShell( + messages: messages, + busy: isRunning, + onSubmit: (text) => agentRuntime.send(text), + onToolAction: (action) => agentRuntime.handleToolAction(action), +) +``` + +## Intended Composition + +IOP standalone: + +```text +iop/apps/client + -> iop_console_flutter + -> agent_shell +``` + +NomadCode without IOP: + +```text +nomadcode/apps/client + -> nomadcode workbench shell + -> agent_shell +``` + +NomadCode with IOP: + +```text +nomadcode optional composition layer + -> nomadcode workbench shell + -> iop_console_flutter + -> agent_shell +``` + +The IOP import must stay in the optional composition layer so the default NomadCode build can succeed without IOP dependencies. diff --git a/agent-ops/rules/project/rules.md b/agent-ops/rules/project/rules.md new file mode 100644 index 0000000..b5b5b7f --- /dev/null +++ b/agent-ops/rules/project/rules.md @@ -0,0 +1,32 @@ +# agent-shell project rules + +## 응답 언어 + +- 기본 응답은 한국어로 한다. + +## 프로젝트 개요 + +`agent-shell`은 Flutter 기반 agent interaction package다. +제품 앱, backend, runtime, WebView 조작, IOP Control Plane, NomadCode workflow를 직접 소유하지 않고, host 앱이 주입하는 메시지와 capability를 표시하고 상호작용하는 공통 UI/인터페이스만 제공한다. + +## 주요 구조 + +- `lib/agent_shell.dart` - public export entrypoint. +- `lib/src/domain/` - message, tool call, command, event, capability model. +- `lib/src/widgets/` - embeddable chat surface와 UI primitives. +- `test/` - widget/model tests. + +## 기술 스택 + +- Flutter package +- Dart SDK `^3.11.0` +- `flutter_test` + +## 프로젝트 특화 컨벤션 + +- `lib/`는 package 코드만 둔다. `runApp`, `MaterialApp`, Firebase/SystemChrome bootstrap은 host 앱 책임이다. +- 제품별 package를 import하지 않는다. IOP/NomadCode 연결은 각 제품의 capability pack 또는 optional composition layer에서 구현한다. +- Public API는 `lib/agent_shell.dart`에서 export한다. +- Host가 theme, navigation, transport, auth, endpoint, persistence를 주입한다. +- UI는 좌측 rail, 우측 rail, center content, modal 등 다양한 host layout에 들어갈 수 있어야 한다. +- 변경 후 가능한 경우 `flutter test`를 실행한다. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..a862a18 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_const_constructors: true + prefer_final_fields: true diff --git a/lib/agent_shell.dart b/lib/agent_shell.dart new file mode 100644 index 0000000..878b37b --- /dev/null +++ b/lib/agent_shell.dart @@ -0,0 +1,10 @@ +export 'src/domain/agent_capability.dart'; +export 'src/domain/agent_command.dart'; +export 'src/domain/agent_event.dart'; +export 'src/domain/agent_message.dart'; +export 'src/domain/agent_tool_call.dart'; +export 'src/widgets/agent_composer.dart'; +export 'src/widgets/agent_message_list.dart'; +export 'src/widgets/agent_shell.dart'; +export 'src/widgets/approval_prompt.dart'; +export 'src/widgets/tool_call_card.dart'; diff --git a/lib/src/domain/agent_capability.dart b/lib/src/domain/agent_capability.dart new file mode 100644 index 0000000..7fbc85a --- /dev/null +++ b/lib/src/domain/agent_capability.dart @@ -0,0 +1,35 @@ +import 'agent_command.dart'; +import 'agent_event.dart'; + +abstract interface class AgentCapability { + String get id; + String get label; + String get category; + + Future describe(); + + Stream invoke(AgentCommand command, AgentContext context); +} + +class AgentCapabilityDescriptor { + final String id; + final String label; + final String category; + final String description; + final bool requiresApproval; + + const AgentCapabilityDescriptor({ + required this.id, + required this.label, + required this.category, + this.description = '', + this.requiresApproval = false, + }); +} + +class AgentContext { + final String? conversationId; + final Map values; + + const AgentContext({this.conversationId, this.values = const {}}); +} diff --git a/lib/src/domain/agent_command.dart b/lib/src/domain/agent_command.dart new file mode 100644 index 0000000..bbbed46 --- /dev/null +++ b/lib/src/domain/agent_command.dart @@ -0,0 +1,22 @@ +class AgentCommand { + final String id; + final String text; + final String? capabilityId; + final Map payload; + + const AgentCommand({ + required this.id, + required this.text, + this.capabilityId, + this.payload = const {}, + }); +} + +class AgentToolAction { + final String toolCallId; + final AgentToolActionType type; + + const AgentToolAction({required this.toolCallId, required this.type}); +} + +enum AgentToolActionType { approve, reject, cancel, retry } diff --git a/lib/src/domain/agent_event.dart b/lib/src/domain/agent_event.dart new file mode 100644 index 0000000..05725ca --- /dev/null +++ b/lib/src/domain/agent_event.dart @@ -0,0 +1,32 @@ +import 'agent_message.dart'; +import 'agent_tool_call.dart'; + +sealed class AgentEvent { + const AgentEvent(); +} + +class AgentMessageEvent extends AgentEvent { + final AgentMessage message; + + const AgentMessageEvent(this.message); +} + +class AgentToolCallEvent extends AgentEvent { + final AgentToolCall toolCall; + + const AgentToolCallEvent(this.toolCall); +} + +class AgentStatusEvent extends AgentEvent { + final String label; + final bool busy; + + const AgentStatusEvent({required this.label, this.busy = false}); +} + +class AgentErrorEvent extends AgentEvent { + final String message; + final Object? details; + + const AgentErrorEvent(this.message, {this.details}); +} diff --git a/lib/src/domain/agent_message.dart b/lib/src/domain/agent_message.dart new file mode 100644 index 0000000..693e399 --- /dev/null +++ b/lib/src/domain/agent_message.dart @@ -0,0 +1,19 @@ +import 'agent_tool_call.dart'; + +enum AgentMessageRole { user, assistant, system, tool } + +class AgentMessage { + final String id; + final AgentMessageRole role; + final String text; + final DateTime? createdAt; + final List toolCalls; + + const AgentMessage({ + required this.id, + required this.role, + required this.text, + this.createdAt, + this.toolCalls = const [], + }); +} diff --git a/lib/src/domain/agent_tool_call.dart b/lib/src/domain/agent_tool_call.dart new file mode 100644 index 0000000..460813c --- /dev/null +++ b/lib/src/domain/agent_tool_call.dart @@ -0,0 +1,30 @@ +enum AgentToolCallStatus { + pending, + awaitingApproval, + running, + succeeded, + failed, + canceled, +} + +class AgentToolCall { + final String id; + final String name; + final AgentToolCallStatus status; + final String summary; + final Map arguments; + final String? resultText; + final String? errorText; + + const AgentToolCall({ + required this.id, + required this.name, + required this.status, + this.summary = '', + this.arguments = const {}, + this.resultText, + this.errorText, + }); + + bool get needsApproval => status == AgentToolCallStatus.awaitingApproval; +} diff --git a/lib/src/widgets/agent_composer.dart b/lib/src/widgets/agent_composer.dart new file mode 100644 index 0000000..9bb3265 --- /dev/null +++ b/lib/src/widgets/agent_composer.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +class AgentComposer extends StatefulWidget { + final ValueChanged? onSubmit; + final bool enabled; + final bool busy; + final String placeholder; + + const AgentComposer({ + super.key, + this.onSubmit, + this.enabled = true, + this.busy = false, + this.placeholder = 'Message the agent', + }); + + @override + State createState() => _AgentComposerState(); +} + +class _AgentComposerState extends State { + final TextEditingController _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final text = _controller.text.trim(); + if (text.isEmpty || !widget.enabled || widget.onSubmit == null) return; + widget.onSubmit!(text); + _controller.clear(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + enabled: widget.enabled, + minLines: 1, + maxLines: 5, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + hintText: widget.placeholder, + border: const OutlineInputBorder(), + isDense: true, + ), + onSubmitted: (_) => _submit(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + tooltip: widget.busy ? 'Agent is running' : 'Send', + onPressed: widget.enabled ? _submit : null, + icon: widget.busy + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/widgets/agent_message_list.dart b/lib/src/widgets/agent_message_list.dart new file mode 100644 index 0000000..ccb3b1e --- /dev/null +++ b/lib/src/widgets/agent_message_list.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import '../domain/agent_command.dart'; +import '../domain/agent_message.dart'; +import 'tool_call_card.dart'; + +class AgentMessageList extends StatelessWidget { + final List messages; + final ValueChanged? onToolAction; + final Widget? emptyState; + + const AgentMessageList({ + super.key, + required this.messages, + this.onToolAction, + this.emptyState, + }); + + @override + Widget build(BuildContext context) { + if (messages.isEmpty) { + return Center( + child: + emptyState ?? + Text( + 'No messages yet', + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: messages.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final message = messages[index]; + return _AgentMessageBubble( + message: message, + onToolAction: onToolAction, + ); + }, + ); + } +} + +class _AgentMessageBubble extends StatelessWidget { + final AgentMessage message; + final ValueChanged? onToolAction; + + const _AgentMessageBubble({required this.message, this.onToolAction}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isUser = message.role == AgentMessageRole.user; + final alignment = isUser ? Alignment.centerRight : Alignment.centerLeft; + final color = isUser + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHighest; + final textColor = isUser + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant; + + return Align( + alignment: alignment, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message.text, + style: theme.textTheme.bodyMedium?.copyWith(color: textColor), + ), + for (final toolCall in message.toolCalls) ...[ + const SizedBox(height: 10), + ToolCallCard(toolCall: toolCall, onAction: onToolAction), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/agent_shell.dart b/lib/src/widgets/agent_shell.dart new file mode 100644 index 0000000..ea5143a --- /dev/null +++ b/lib/src/widgets/agent_shell.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import '../domain/agent_command.dart'; +import '../domain/agent_message.dart'; +import 'agent_composer.dart'; +import 'agent_message_list.dart'; + +class AgentShell extends StatelessWidget { + final List messages; + final ValueChanged? onSubmit; + final ValueChanged? onToolAction; + final bool busy; + final String placeholder; + final Widget? emptyState; + + const AgentShell({ + super.key, + required this.messages, + this.onSubmit, + this.onToolAction, + this.busy = false, + this.placeholder = 'Message the agent', + this.emptyState, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: AgentMessageList( + messages: messages, + onToolAction: onToolAction, + emptyState: emptyState, + ), + ), + const Divider(height: 1), + AgentComposer( + enabled: !busy && onSubmit != null, + busy: busy, + placeholder: placeholder, + onSubmit: onSubmit, + ), + ], + ); + } +} diff --git a/lib/src/widgets/approval_prompt.dart b/lib/src/widgets/approval_prompt.dart new file mode 100644 index 0000000..0382778 --- /dev/null +++ b/lib/src/widgets/approval_prompt.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import '../domain/agent_command.dart'; +import '../domain/agent_tool_call.dart'; + +class ApprovalPrompt extends StatelessWidget { + final AgentToolCall toolCall; + final ValueChanged? onAction; + + const ApprovalPrompt({super.key, required this.toolCall, this.onAction}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: onAction == null + ? null + : () => onAction!( + AgentToolAction( + toolCallId: toolCall.id, + type: AgentToolActionType.reject, + ), + ), + icon: const Icon(Icons.close), + label: const Text('Reject'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: onAction == null + ? null + : () => onAction!( + AgentToolAction( + toolCallId: toolCall.id, + type: AgentToolActionType.approve, + ), + ), + icon: const Icon(Icons.check), + label: const Text('Approve'), + ), + ], + ); + } +} diff --git a/lib/src/widgets/tool_call_card.dart b/lib/src/widgets/tool_call_card.dart new file mode 100644 index 0000000..6e4d396 --- /dev/null +++ b/lib/src/widgets/tool_call_card.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import '../domain/agent_command.dart'; +import '../domain/agent_tool_call.dart'; +import 'approval_prompt.dart'; + +class ToolCallCard extends StatelessWidget { + final AgentToolCall toolCall; + final ValueChanged? onAction; + + const ToolCallCard({super.key, required this.toolCall, this.onAction}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(_statusIcon(toolCall.status), size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + toolCall.name, + style: theme.textTheme.titleSmall, + overflow: TextOverflow.ellipsis, + ), + ), + _StatusLabel(status: toolCall.status), + ], + ), + if (toolCall.summary.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(toolCall.summary), + ], + if (toolCall.arguments.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + toolCall.arguments.toString(), + style: theme.textTheme.bodySmall, + ), + ], + if (toolCall.resultText case final result?) ...[ + const SizedBox(height: 8), + Text(result, style: theme.textTheme.bodySmall), + ], + if (toolCall.errorText case final error?) ...[ + const SizedBox(height: 8), + Text( + error, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + if (toolCall.needsApproval) ...[ + const SizedBox(height: 12), + ApprovalPrompt(toolCall: toolCall, onAction: onAction), + ], + ], + ), + ), + ); + } +} + +class _StatusLabel extends StatelessWidget { + final AgentToolCallStatus status; + + const _StatusLabel({required this.status}); + + @override + Widget build(BuildContext context) { + return Text(switch (status) { + AgentToolCallStatus.pending => 'Pending', + AgentToolCallStatus.awaitingApproval => 'Approval', + AgentToolCallStatus.running => 'Running', + AgentToolCallStatus.succeeded => 'Done', + AgentToolCallStatus.failed => 'Failed', + AgentToolCallStatus.canceled => 'Canceled', + }, style: Theme.of(context).textTheme.labelSmall); + } +} + +IconData _statusIcon(AgentToolCallStatus status) { + return switch (status) { + AgentToolCallStatus.pending => Icons.schedule, + AgentToolCallStatus.awaitingApproval => Icons.verified_user, + AgentToolCallStatus.running => Icons.sync, + AgentToolCallStatus.succeeded => Icons.check_circle, + AgentToolCallStatus.failed => Icons.error, + AgentToolCallStatus.canceled => Icons.cancel, + }; +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..b55921f --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,19 @@ +name: agent_shell +description: Product-agnostic Flutter widgets and interfaces for embeddable agent interaction surfaces. +version: 0.1.0 +publish_to: 'none' + +environment: + sdk: ^3.11.0 + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/test/agent_shell_test.dart b/test/agent_shell_test.dart new file mode 100644 index 0000000..c044bbb --- /dev/null +++ b/test/agent_shell_test.dart @@ -0,0 +1,61 @@ +import 'package:agent_shell/agent_shell.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('submits composer text', (tester) async { + String? submitted; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AgentShell( + messages: const [], + onSubmit: (text) => submitted = text, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'hello'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pump(); + + expect(submitted, 'hello'); + }); + + testWidgets('emits approval action for tool calls', (tester) async { + AgentToolAction? action; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AgentShell( + messages: const [ + AgentMessage( + id: 'm1', + role: AgentMessageRole.assistant, + text: 'I need approval.', + toolCalls: [ + AgentToolCall( + id: 'tool-1', + name: 'restart-node', + status: AgentToolCallStatus.awaitingApproval, + summary: 'Restart a node', + ), + ], + ), + ], + onToolAction: (next) => action = next, + ), + ), + ), + ); + + await tester.tap(find.text('Approve')); + await tester.pump(); + + expect(action?.toolCallId, 'tool-1'); + expect(action?.type, AgentToolActionType.approve); + }); +}