Full agent admin via API and UI

This commit is contained in:
Shivering Ocelot committed 2025-05-25 18:59:23 -05:00
1 parent 5351144edf
commit 08f0668997
7 files changed
+389 -32

No files matched your search

+24 -10
View File
@@ -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):
+60 -1
View File
@@ -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={}
)
+21 -5
View File
@@ -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)
+129 -8
View File
@@ -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<List<String>> getAgentKinds() async {
final response = await Backend.sendAction("get-agent-kinds", {});
return response['kinds'];
}
// Function to create a new agent and navigate
Future<void> 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: <Widget>[
TextField(
controller: usernameController,
decoration: const InputDecoration(hintText: "Enter agent name"),
),
const SizedBox(height: 16.0), // Spacing between fields
FutureBuilder<List<String>>(
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<String>(
value: selectedKind,
hint: const Text('Select Kind'),
decoration: const InputDecoration(
labelText: 'Kind',
border: OutlineInputBorder(),
),
items: kinds.map((String kind) {
return DropdownMenuItem<String>(
value: kind,
child: Text(kind),
);
}).toList(),
onChanged: (String? newValue) {
setState(() {
selectedKind = newValue;
});
},
);
},
);
}
},
),
],
),
),
actions: <Widget>[
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),
),
);*/
);
},
);
},
+105
View File
@@ -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<AgentScreen> {
String? _title;
final TextEditingController nameController = TextEditingController();
List<ConfigField> 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'),
),
],
),
),
),
);
}
}
+5
View File
@@ -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:
+45 -8
View File
@@ -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();