Scaffold agent shell package

This commit is contained in:
toki 2026-05-31 17:55:17 +09:00
commit 09080ca7f6
18 changed files with 738 additions and 0 deletions

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
.dart_tool/
.packages
build/
coverage/
pubspec.lock
# IDE/local
.idea/
.vscode/
*.iml
# Generated by local experiments
.tmp/

28
AGENTS.md Normal file
View file

@ -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`

70
README.md Normal file
View file

@ -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.

View file

@ -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`를 실행한다.

6
analysis_options.yaml Normal file
View file

@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
prefer_final_fields: true

10
lib/agent_shell.dart Normal file
View file

@ -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';

View file

@ -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<AgentCapabilityDescriptor> describe();
Stream<AgentEvent> 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<String, Object?> values;
const AgentContext({this.conversationId, this.values = const {}});
}

View file

@ -0,0 +1,22 @@
class AgentCommand {
final String id;
final String text;
final String? capabilityId;
final Map<String, Object?> 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 }

View file

@ -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});
}

View file

@ -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<AgentToolCall> toolCalls;
const AgentMessage({
required this.id,
required this.role,
required this.text,
this.createdAt,
this.toolCalls = const [],
});
}

View file

@ -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<String, Object?> 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;
}

View file

@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
class AgentComposer extends StatefulWidget {
final ValueChanged<String>? 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<AgentComposer> createState() => _AgentComposerState();
}
class _AgentComposerState extends State<AgentComposer> {
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),
),
],
),
),
);
}
}

View file

@ -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<AgentMessage> messages;
final ValueChanged<AgentToolAction>? 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<AgentToolAction>? 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),
],
],
),
),
),
),
);
}
}

View file

@ -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<AgentMessage> messages;
final ValueChanged<String>? onSubmit;
final ValueChanged<AgentToolAction>? 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,
),
],
);
}
}

View file

@ -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<AgentToolAction>? 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'),
),
],
);
}
}

View file

@ -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<AgentToolAction>? 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,
};
}

19
pubspec.yaml Normal file
View file

@ -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

View file

@ -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);
});
}