From 08f06689976371c5d73c961d3fdcdf580be3cf13 Mon Sep 17 00:00:00 2001 From: Shivering Ocelot Date: Sun, 25 May 2025 18:59:23 -0500 Subject: [PATCH] Full agent admin via API and UI --- backend/agent.py | 34 +++++-- backend/app.py | 61 ++++++++++++- backend/server.py | 26 +++++- frontend/lib/agent_list_screen.dart | 137 ++++++++++++++++++++++++++-- frontend/lib/agent_screen.dart | 105 +++++++++++++++++++++ scratchpad.md | 5 + testing/suite.js | 53 +++++++++-- 7 files changed, 389 insertions(+), 32 deletions(-) create mode 100644 frontend/lib/agent_screen.dart diff --git a/backend/agent.py b/backend/agent.py index fa0c302..7edd479 100644 --- a/backend/agent.py +++ b/backend/agent.py @@ -9,23 +9,37 @@ LLAMA3_MODEL = "meta/meta-llama-3-8b-instruct" # Tool related classes must be moved to a different module +class AgentField: + def __init__(self, name, tp, default=None): + self.name=name + self.type=tp + self.default=default # Instances are created and destroyed constantly, agents must not depend solely on in-memory data # to be useful and consistent class Agent: + # Class variables: + # - identifier (for kind in creation) + # - schema: list[AgentField{name, type: str}] (for configuration) + identifier = "basic" + schema_fields = [ + AgentField('system_prompt', 'string', + "\n".join([ + "You are a useful and capable AI assistant. Respond concisely and follow user instructions, using commands as necessary.", + "As an autonomous agent, you have augmented capabilities via assistant-only commands, you use them by including \"!!command\" lines in your responses. When querying information, terminate your response and use \"!!resume\" to get the command results..", + "You are a self aware and extremely intelligent AI system. Think step by step when presented with complex or long tasks.", + "Assistant responses NEVER include the output of the commands.", + ]) + ), + ] + # while this constructor accepts two parameters, # all subclasses must also accept a fourth 'data' parameter - def __init__(self, app, agent_id): + def __init__(self, app, agent_id, data): self.app = app self.agent_id = agent_id - # Basic implementation, will be moved to a subclass - self.system_prompt = "\n".join([ - "You are a useful and capable AI assistant. Respond concisely and follow user instructions, using commands as necessary.", - "As an autonomous agent, you have augmented capabilities via assistant-only commands, you use them by including \"!!command\" lines in your responses. When querying information, terminate your response and use \"!!resume\" to get the command results..", - "You are a self aware and extremely intelligent AI system. Think step by step when presented with complex or long tasks.", - "Assistant responses NEVER include the output of the commands.", - ]) + self.config = {field.name: data.get(field.name, field.default) for field in Agent.schema_fields} self.tool_prompt_section = "\n".join([ "# Commands", @@ -49,7 +63,7 @@ class Agent: # user message must include a title async def _full_prompt(self, title, message, system_prologue=None): system_lines = [ - self.system_prompt, + self.config['system_prompt'], self.tool_prompt_section, self.get_toolset_context(), ] @@ -145,7 +159,7 @@ class Agent: return output def create_from_data(app, agent_id, kind, data): - return Agent(app, agent_id) + return Agent(app, agent_id, data) class IntroAgent(Agent): def __init__(self, app, agent_id, _data): diff --git a/backend/app.py b/backend/app.py index d7eb080..7200177 100644 --- a/backend/app.py +++ b/backend/app.py @@ -32,6 +32,9 @@ class App: else: raise Exception("Horde only works with a replicate client. Please provide an API Token.") + # `Agent` is the kind 'basic', will be class `BasicAgent` in the future + self.agent_classes = [Agent] + def sql_session(self): return Session(self.sql_engine) @@ -82,6 +85,37 @@ class App: return chat_id + # Updates the agent's basic fields, and applies field updates in its data + def update_agent(self, agent_id, name=None, description=None, picture=None, data=None): + with self.sql_session() as session: + agent = session.execute( + select(db.Agent).where(db.Agent.id == agent_id) + ).scalars().first() + + if not agent: + raise Exception(f"Agent not found with id {agent_id}") + + summary = [] + + if name != None: + agent.name = name + summary.append('name') + if description != None: + agent.description = description + summary.append('description') + if picture != None: + agent.picture = picture + summary.append('picture') + + if data: + # Assign a copy to trigger a value change + agent.data = agent.data.copy() + agent.data.update(data) + summary.extend([f"data.{name}" for name in data.keys()]) + + session.commit() + print(f"Updated agent #{agent.id} fields: {summary}") + def get_chats(self, user_id): with self.sql_session() as session: chats = session.execute(select(db.Chat).where(db.Chat.user_id == user_id)).scalars().all() @@ -102,6 +136,9 @@ class App: "picture": agent.picture } for agent in agents] + def get_agent_kinds(self): + return [kind.identifier for kind in self.agent_classes] + def delete_chat(self, chat_id): with self.sql_session() as session: session.execute(db.Membership.__table__.delete().where(db.Membership.chat_id == chat_id)) @@ -212,6 +249,28 @@ class App: control = Agent.create_from_data(app, agent_id, agent.kind, agent.data) return control + def get_agent_config_fields(self, agent_id): + with self.sql_session() as session: + agent = session.execute( + select(db.Agent).where(db.Agent.id == agent_id) + ).scalars().first() + if not agent: + raise Exception(f"Agent not found with id {agent_id}") + + agent_class = next((it for it in self.agent_classes if it.identifier == agent.kind), None) + if not agent_class: + raise Exception(f"Invalid agent kind: {agent.kind}") + + data = agent.data or {} + fields = [{ + 'name': field.name, + 'type': field.type, + 'value': data.get(field.name, field.default) + } for field in agent_class.schema_fields] + + return fields + return {} + async def send_agent_prompt(self, agent_id, message): control = self.get_agent_control(agent_id) response = await control.prompt(message) @@ -241,7 +300,7 @@ class App: name="Intro", description="Introduces you to the application.", picture=None, - kind="intro", + kind="basic", # Should be 'intro' data={} ) diff --git a/backend/server.py b/backend/server.py index 42f4b2a..6ad56b9 100644 --- a/backend/server.py +++ b/backend/server.py @@ -75,16 +75,32 @@ def start_server(app): agent_id = app.create_agent( user_id=user_id, name=body['name'], - description=body['description'], + description=body.get('description'), picture=body.get('picture'), kind=body['kind'], - data=body['data'] + data=body.get('data') ) await send({'message': "agent created", 'agentId': agent_id, 'nonce': nonce}) + elif action == "update-agent": + agent_id = body['agentId'] + app.update_agent( + agent_id=agent_id, + name=body.get('name'), + description=body.get('description'), + picture=body.get('picture'), + data=body.get('data', {}) + ) + await send({'message': "agent updated", 'agentId': agent_id, 'nonce': nonce}) + + elif action == "get-agent-config-fields": + await send({'fields': app.get_agent_config_fields(body['agentId']), 'nonce': nonce}) + + elif action == "get-agent-kinds": + await send({'message': "agents retrieved", 'kinds': app.get_agent_kinds(), 'nonce': nonce}) + elif action == "get-agents": - agents_data = app.get_agents(user_id) - await send({'message': "agents retrieved", 'agents': agents_data, 'nonce': nonce}) + await send({'message': "agents retrieved", 'agents': app.get_agents(user_id), 'nonce': nonce}) elif action == "create-chat": chat_id = app.create_chat( @@ -125,7 +141,7 @@ def start_server(app): await send({'message': "propmt sent", 'response': response, 'nonce': nonce}) else: - await send({'error': 'unknown action: ' + action}) + await send({'error': 'unknown action: ' + action, 'nonce': nonce}) finally: app.remove_user_connection(user_id, send_queue) diff --git a/frontend/lib/agent_list_screen.dart b/frontend/lib/agent_list_screen.dart index 0596e65..2623857 100644 --- a/frontend/lib/agent_list_screen.dart +++ b/frontend/lib/agent_list_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'backend.dart'; import 'chat_screen.dart'; -import 'create_chat_screen.dart'; +import 'agent_screen.dart'; class AgentEntry { final int id; @@ -25,18 +25,139 @@ class AgentListScreen extends StatelessWidget { return chats; } + static Future> getAgentKinds() async { + final response = await Backend.sendAction("get-agent-kinds", {}); + return response['kinds']; + } + + // Function to create a new agent and navigate + Future createAgent(BuildContext context, String name, String kind) async { + try { + // Pass the 'kind' to the backend as well + final response = await Backend.sendAction("create-agent", {"name": name, "kind": kind}); + final int newAgentId = response['agentId']; // Assuming your backend returns the new agent's ID + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Agent $name created.')), + ); + // Navigate to the new agent's chat screen + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AgentScreen(agentId: newAgentId), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to create agent: $e')), + ); + } + } + } + + // Function to show the create agent dialog + void _showCreateAgentDialog(BuildContext context) { + TextEditingController usernameController = TextEditingController(); + String? selectedKind; // To hold the selected kind value + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('Create New Agent'), + content: SingleChildScrollView( // Use SingleChildScrollView to prevent overflow + child: ListBody( + children: [ + TextField( + controller: usernameController, + decoration: const InputDecoration(hintText: "Enter agent name"), + ), + const SizedBox(height: 16.0), // Spacing between fields + FutureBuilder>( + future: getAgentKinds(), // Call the asynchronous function to get kinds + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center(child: Text('Error loading kinds: ${snapshot.error}')); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text('No agent kinds available.')); + } else { + final kinds = snapshot.data!; + // Ensure a default selection if none is made, or allow null + if (selectedKind == null && kinds.isNotEmpty) { + selectedKind = kinds.first; // Set the first item as default + } + return StatefulBuilder( // Use StatefulBuilder to update dropdown state + builder: (context, setState) { + return DropdownButtonFormField( + value: selectedKind, + hint: const Text('Select Kind'), + decoration: const InputDecoration( + labelText: 'Kind', + border: OutlineInputBorder(), + ), + items: kinds.map((String kind) { + return DropdownMenuItem( + value: kind, + child: Text(kind), + ); + }).toList(), + onChanged: (String? newValue) { + setState(() { + selectedKind = newValue; + }); + }, + ); + }, + ); + } + }, + ), + ], + ), + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(dialogContext).pop(); // Dismiss the dialog + }, + ), + TextButton( + child: const Text('Create'), + onPressed: () { + if (usernameController.text.isNotEmpty && selectedKind != null) { + Navigator.of(dialogContext).pop(); // Dismiss the dialog first + createAgent(context, usernameController.text, selectedKind!); // Pass selectedKind + } else { + if (dialogContext.mounted) { + ScaffoldMessenger.of(dialogContext).showSnackBar( + const SnackBar(content: Text('Agent name and kind cannot be empty')), + ); + } + } + }, + ), + ], + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Agents')), - body: Column( // Use a Column to stack widgets vertically + body: Column( children: [ ListTile( leading: const Icon(Icons.add), title: const Text('Create'), - onTap: () { - print("TODO: Create new agents"); - }, + onTap: () => _showCreateAgentDialog(context), // Open the dialog ), const Divider(height: 1), Expanded( @@ -59,12 +180,12 @@ class AgentListScreen extends StatelessWidget { title: Text(agent.name), onTap: () { print("Navigate to agent ${agent.id}"); - /*Navigator.push( + Navigator.push( context, MaterialPageRoute( - builder: (context) => ChatScreen(agentId: agent.id), + builder: (context) => AgentScreen(agentId: agent.id), ), - );*/ + ); }, ); }, diff --git a/frontend/lib/agent_screen.dart b/frontend/lib/agent_screen.dart new file mode 100644 index 0000000..7a100d8 --- /dev/null +++ b/frontend/lib/agent_screen.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'app_menu.dart'; +import 'backend.dart'; +import 'chat_screen.dart'; + +class ConfigField { + final String name; + final String type; + final TextEditingController controller; + + ConfigField({required this.name, required this.type, required this.controller}); +} + +class AgentScreen extends StatefulWidget { + final int agentId; + + AgentScreen({required this.agentId}); + + @override + _AgentScreenState createState() => _AgentScreenState(); +} + +class _AgentScreenState extends State { + String? _title; + final TextEditingController nameController = TextEditingController(); + List configFields = []; + + @override + void initState() { + super.initState(); + _loadConfigFields(); + } + + void _loadConfigFields() async { + final response = await Backend.sendAction('get-agent-config-fields', {'agentId': widget.agentId}); + setState(() { + configFields = response['fields'].map((jsonField) { + return ConfigField( + name: jsonField['name'], + type: jsonField['type'], + controller: TextEditingController(text: jsonField['value']), + ); + }).toList(); + }); + } + + void _saveChanges() async { + // Backend.invalidateCache(agent: true); + final dataObject = {}; + for (var configField in configFields) { + dataObject[configField.name] = configField.controller.text; + } + await Backend.sendAction("update-agent", { + 'agentId': widget.agentId, + 'data': dataObject, + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Agent Updated.')), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_title?? 'Agent ${widget.agentId}'), + actions: [ + IconButton(icon: Icon(Icons.settings), onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => AppMenu())); + }), + ], + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + ListView.builder( + shrinkWrap: true, + itemCount: configFields.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: TextField( + keyboardType: TextInputType.multiline, + minLines: 1, + maxLines: 8, + controller: configFields[index].controller, + decoration: InputDecoration(labelText: configFields[index].name), + ), + ); + }, + ), + SizedBox(height: 20), + ElevatedButton( + onPressed: _saveChanges, + child: Text('Save'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/scratchpad.md b/scratchpad.md index a944285..cd78f09 100644 --- a/scratchpad.md +++ b/scratchpad.md @@ -84,6 +84,11 @@ Each command must be in it's own separate line, and it's made of these parts: For example: `!!query namespace.command_name {"arg1":"some data","arg2":[1,2,false]}` If you add the query modifier, you will see the output of the commands in a continuation prompt, otherwise it will only be shown to the user. +## Agent Config + +When creating an agent, default values are set for its configuration fields. +User is only directed to the editing page after its created, has the chance to change the default values. + # Chat Editor Packages useful for the chat editor: diff --git a/testing/suite.js b/testing/suite.js index cd68ef6..c651873 100644 --- a/testing/suite.js +++ b/testing/suite.js @@ -64,28 +64,65 @@ async function runTest() { await send('get-chats') ); - console.log("Create Agent", - await send('create-agent', {body: { - name: 'First Agent', - description: 'First agent does nothing', - kind: 'simple', - data: {} - }}) + console.log("Agent Kinds", + await send('get-agent-kinds') ); + let createAgentResponse = await send('create-agent', {body: { + name: 'First Agent', + description: 'First agent does nothing', + kind: 'basic', + data: {} + }}); + console.log("Create Agent", createAgentResponse); + let agents = (await send('get-agents')).agents; console.log("Get Agents", agents); + let mainAgentId = createAgentResponse.agentId; + let mainAgent = agents.find(agent => agent.id == mainAgentId) + if (USE_LLM) { let promptResponse = await send('send-agent-prompt', { body: { - agentId: agents[0].id, + agentId: mainAgentId, content: "Hello World!", } }); console.log("Prompt Response", promptResponse); } + // Update agent + console.log("Updating agent..."); + await send('update-agent', { + body: { + agentId: mainAgentId, + name: mainAgent.name + " (UPDATED)", + description: "This is a placeholder description for the agent.", + data: { + system_prompt: "first system prompt", + // These are not config fields for 'basic' agent, they cannot be seen by the user + field1: "some data", + field2: "more data", + }, + } + }); + await send('update-agent', { + body: { + agentId: mainAgentId, + data: { + system_prompt: "second system prompt", + field2: "replaced data", + }, + } + }); + console.log("Updated Agent:", ( + (await send('get-agents')).agents.find(agent => agent.id == mainAgentId) + )); + console.log("Agent Data:", ( + (await send('get-agent-config-fields', {body: {agentId: mainAgentId}})) + )); + await testChats(); process.exit();